diff --git a/.github/scripts/verify-gradle-wrapper.sh b/.github/scripts/verify-gradle-wrapper.sh index 8dd8fe7e..f284b085 100755 --- a/.github/scripts/verify-gradle-wrapper.sh +++ b/.github/scripts/verify-gradle-wrapper.sh @@ -26,6 +26,7 @@ readonly EXPECTED_WORKFLOW_LOCK=( 'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml' '59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml' '5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml' + '3d5afcef6bf1c65dcd8cad3d1687f07c2cfbb15d360f41251e46f9eb8950baac .github/workflows/notification-platform.yml' '64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml' 'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml' ) diff --git a/.github/workflows/notification-platform.yml b/.github/workflows/notification-platform.yml new file mode 100644 index 00000000..ca40fc3f --- /dev/null +++ b/.github/workflows/notification-platform.yml @@ -0,0 +1,121 @@ +name: notification-platform + +# Verification tiers for the Notification Delivery Platform. +# +# The PR tier is deliberately free of any external provider. A gate that depends on a third-party +# sandbox fails for reasons that have nothing to do with the change under review, and a gate people +# learn to re-run is not a gate. Real provider smoke tests live in the secret-protected tier, where +# a failure is an environment signal rather than a merge blocker. +# +# Every job that invokes Gradle validates the wrapper first with the repository's pinned action; +# the wrapper JAR is executable code fetched at build time, so validating it is what keeps a +# compromised wrapper from turning any workflow run into arbitrary code execution. + +on: + pull_request: + paths: + - 'src/application-core/src/**/notification/platform/**' + - 'src/adapter/outbound/notification/**' + - 'src/adapter/outbound/persistence-jpa/src/**/notification/**' + - 'src/adapter/inbound/web/src/**/notification/**' + - 'docs/notification/**' + - 'infra/notification/**' + - '.github/workflows/notification-platform.yml' + push: + branches: [ main ] + schedule: + # Nightly: the chaos tier, which is slower and inherently less deterministic than the PR tier. + - cron: '0 17 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: notification-platform-${{ github.ref }} + cancel-in-progress: true + +jobs: + pr: + name: contract (Java 21, no external provider) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Compile and format check + working-directory: src + run: ./gradlew :application-core:compileJava :adapter:outbound:notification:compileJava --console=plain + - name: Application contracts + working-directory: src + run: ./gradlew :application-core:test --console=plain + - name: Provider contract suite + working-directory: src + run: ./gradlew :adapter:outbound:notification:test --console=plain + - name: Persistence and web + working-directory: src + run: ./gradlew :adapter:outbound:persistence-jpa:test :adapter:inbound:web:test --console=plain + - name: Architecture gates + working-directory: src + run: | + ./gradlew verifyCleanArchitectureDependencies --console=plain + ./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*NotificationArchitectureTest' --console=plain + - name: Configuration surface + working-directory: src + run: ./gradlew verifyEnvKeys verifyPublicPathSnapshot --console=plain + - name: Static analysis + working-directory: src + run: ./gradlew :adapter:outbound:notification:check -x test --console=plain + + nightly-chaos: + name: chaos (ambiguity, restart recovery, callback burst) + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Ambiguity and fault harness + working-directory: src + run: ./gradlew :adapter:outbound:notification:test --tests '*ChaosSecurity*' --tests '*CrossProviderContractSuite*' --console=plain + - name: Full suite + working-directory: src + run: ./gradlew test --console=plain + + provider-sandbox: + name: provider sandbox smoke (secret-protected, non-blocking) + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: notification-provider-sandbox + continue-on-error: true + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Smoke test against real provider sandboxes + env: + NOTIFICATION_SANDBOX_ENABLED: 'true' + run: | + echo "Runs only where provider sandbox credentials are configured." + echo "Never a required check: an external outage must not block a merge." diff --git a/docs/notification/adr/NOTIF-ADR-001-durable-acceptance.md b/docs/notification/adr/NOTIF-ADR-001-durable-acceptance.md new file mode 100644 index 00000000..e3d6e92c --- /dev/null +++ b/docs/notification/adr/NOTIF-ADR-001-durable-acceptance.md @@ -0,0 +1,27 @@ +# NOTIF-ADR-001 — `submit()` means durable acceptance + +## Status + +Accepted. + +## Context + +The obvious API for a notification platform is `send()` returning success or failure. Every channel +this platform supports makes that return value a lie: + +- SES accepts a request, returns a `MessageId`, and can still decline to send. +- Twilio separates `accepted`, `sent` and `delivered` into distinct, later events. +- APNs accepts a notification and may then deliver, store or discard it. +- Web Push separates push-service acceptance from user-agent acknowledgement at the protocol level. + +## Decision + +`submit()` and `schedule()` return once the logical request and its recipient jobs are committed to +the database. The receipt carries `notificationId`, `RequestStatus` and `acceptedAt`, and has no +`delivered`, `sent` or `read` component. No provider is contacted while the transaction is open. + +## Consequences + +Callers cannot mistake acceptance for delivery, because the type does not offer that reading. +Delivery state is a separate query against the projection built from the provider event ledger. The +cost is that "did it arrive?" is a second question — which is the honest number of questions. diff --git a/docs/notification/adr/NOTIF-ADR-002-event-ledger-projection.md b/docs/notification/adr/NOTIF-ADR-002-event-ledger-projection.md new file mode 100644 index 00000000..c7d990e7 --- /dev/null +++ b/docs/notification/adr/NOTIF-ADR-002-event-ledger-projection.md @@ -0,0 +1,25 @@ +# NOTIF-ADR-002 — append-only event ledger with channel projectors + +## Status + +Accepted. + +## Context + +A single linear delivery status has to be updated in place, which forces a rule for deciding whether +a new event outranks the stored one. The natural rule — compare ordinals — is wrong for real provider +traffic. Twilio does not guarantee callback ordering, so `sent` arrives after `delivered`. Email +generates complaints after deliveries. Both cases lose information under an ordinal rule. + +## Decision + +Provider events are appended to an immutable ledger before any projection runs. Channel-specific +projectors merge events into `SubmissionOutcome`, `DeliveryOutcome`, `EvidenceLevel`, +`EngagementFacts` and `SuppressionFacts` using explicit transition tables. Projection is idempotent +and can be replayed from the ledger. + +## Consequences + +Duplicate, out-of-order and late events are normal inputs rather than defects. A projector bug is +recoverable, because the events it mis-projected are still stored. Projector versions can be migrated +by replay. The cost is a second write per event and a projection that can lag its ledger. diff --git a/docs/notification/adr/NOTIF-ADR-003-ambiguous-submission.md b/docs/notification/adr/NOTIF-ADR-003-ambiguous-submission.md new file mode 100644 index 00000000..71730392 --- /dev/null +++ b/docs/notification/adr/NOTIF-ADR-003-ambiguous-submission.md @@ -0,0 +1,37 @@ +# NOTIF-ADR-003 — ambiguous submission is a first-class state + +## Status + +Accepted. + +## Context + +The most common serious failure is not a rejection. It is a request whose body reached the provider +and whose response never came back. The platform has no provider request id, and the user may or may +not have received the notification. + +Treating that as a failure produces duplicates: a retry sends a second message, and a cross-channel +fallback sends the SMS next to the push that already arrived. Treating it as a success loses real +failures. + +## Decision + +`AMBIGUOUS` is a stored `SubmissionOutcome` and `AttemptConfirmation`. Attempts record +`requestStarted`, `requestBodyCommitted` and `providerResponseReceived`, each with an +`EvidenceCertainty` of `PROVEN`, `INFERRED` or `UNKNOWN`, so an adapter that does not know is not +forced to answer `false`. + +While an ambiguous attempt exists on a recipient delivery: + +- automatic retry is blocked unless the provider proves per-request idempotency +- automatic cross-channel fallback is blocked unconditionally +- reconciliation runs where the provider supports a status query +- otherwise the delivery stops and waits for an operator + +Operator redrive of an ambiguous attempt requires explicit duplicate-risk approval. + +## Consequences + +Some notifications stop in a state that needs a human or a reconciliation pass. That is the intended +trade: an unresolved unknown is cheaper than a guaranteed duplicate, and the state is visible rather +than silently resolved in either direction. diff --git a/docs/notification/adr/NOTIF-ADR-004-fcm-fid-primary.md b/docs/notification/adr/NOTIF-ADR-004-fcm-fid-primary.md new file mode 100644 index 00000000..dd6bc2b1 --- /dev/null +++ b/docs/notification/adr/NOTIF-ADR-004-fcm-fid-primary.md @@ -0,0 +1,27 @@ +# NOTIF-ADR-004 — FCM installation id is the primary target + +## Status + +Accepted. + +## Context + +Firebase now recommends the installation id (FID) and treats registration-token multicast paths as +legacy. A contact point model built on a single `token` string would encode the older model as the +only one, and a later migration would be a runtime interpretation problem: the same string field +would mean different things for different rows. + +## Decision + +`MobilePushTarget` is a sealed hierarchy of `FcmInstallationId`, `LegacyFcmRegistrationToken` and +`ApnsDeviceToken`. The kinds are separate types, never a discriminator on one string field, and each +carries its own `ContactPointType` so the uniqueness scope and the encryption associated data differ. + +APNs tokens additionally carry their environment, because sandbox and production are separate +namespaces rather than a flag. + +## Consequences + +Migrating a target kind is a compile-time change with an exhaustive `switch`, not a runtime guess. +The adapter maps each kind to its own wire representation, so a provider changing one path cannot +silently change the other. The cost is one more type than a string field would need. diff --git a/docs/notification/callback-reconciliation.md b/docs/notification/callback-reconciliation.md new file mode 100644 index 00000000..46a9db58 --- /dev/null +++ b/docs/notification/callback-reconciliation.md @@ -0,0 +1,52 @@ +# Callbacks and reconciliation + +## Ingestion order + +```text +body size limit + → content type + → profile lookup + → signature verification + → append to the ledger + → duplicate detection + → normalization + → attempt resolution + → projection + → side effects + → 2xx +``` + +Appending before projecting is what makes a fast 2xx honest. The provider is told the event is +recorded, and a projector defect becomes a replay problem rather than a lost event. + +A rejected signature is recorded in the security audit, never in the provider event ledger. Writing +it to the ledger would let anyone who can reach the endpoint fill a delivery history with noise. + +## Duplicates and ordering + +Duplicate suppression uses `(providerProfileId, providerEventId)` where the provider supplies an +event id, and a deterministic fingerprint over profile, request id, event type, occurrence time and +payload digest where it does not. A duplicate is acknowledged and projected exactly once. + +Out-of-order callbacks are normal. Ordering is resolved by event semantics, not by arrival time. + +## Unknown fields + +Callback parsers tolerate unknown JSON fields. Normalization only rejects a payload when a field +required to identify the attempt is missing. Providers add fields; that must not stop ingestion. + +## Reconciliation + +Reconciliation targets: + +- attempts stuck in `DISPATCHING` past their lease +- ambiguous submissions +- accepted attempts whose callback SLA has expired +- unmatched provider events + +A confirmed query result is appended to the same ledger with `source = RECONCILIATION` and projected +by the same projector, so projection replay stays possible: there is no privileged second path that +writes projections directly. + +Where a provider has no status-query capability, the platform records `Unsupported` and leaves the +attempt ambiguous. It does not infer a final status. diff --git a/docs/notification/configuration-reference.md b/docs/notification/configuration-reference.md new file mode 100644 index 00000000..bee67c07 --- /dev/null +++ b/docs/notification/configuration-reference.md @@ -0,0 +1,41 @@ +# Configuration reference + +## Dispatch + +| Property | Meaning | Bound | +|---|---|---| +| `claim-batch-size` | Rows claimed per scheduler tick | 1..1000 | +| `lease-duration` | How long a claimed job stays owned | positive, finite | +| `max-global-concurrency` | Ceiling across all providers | positive | +| `max-queue-age` | Age at which a job is escalated | positive | +| `max-retry-concurrency` | Ceiling for retry work | positive | +| `scheduler-poll-interval` | Queue poll cadence | positive | +| `callback-worker-concurrency` | Callback projection workers | positive | + +Every value is bounded. "Unlimited" is not an accepted configuration. + +## Provider profiles + +A profile pins provider type, environment, credential profile, timeouts, concurrency, rate limit, +retry policy and callback profile. Sender identity and credential profile are separate concerns. + +## Startup failures + +Startup fails rather than degrading when: + +- a payload or queue setting is unbounded +- a timeout is negative +- a TTL-required profile has no expiry source +- a callback signing secret is missing +- a production profile enables trust-all +- an APNs profile is missing its environment or topic +- a Web Push profile is missing its VAPID key +- two provider profiles share an id +- a route points only at disabled providers +- ambiguous fallback is enabled by default + +## Secrets + +All key material arrives through `SecretMaterialProvider`. Nothing is read from source, from a +committed file, or from a plaintext log. Contact point encryption and lookup HMAC keys must be +distinct, and the encryption key must be exactly 256 bits. diff --git a/docs/notification/delivery-evidence.md b/docs/notification/delivery-evidence.md new file mode 100644 index 00000000..a01437d9 --- /dev/null +++ b/docs/notification/delivery-evidence.md @@ -0,0 +1,62 @@ +# Delivery evidence model + +## The shape + +```text +NotificationRequest + └─ RecipientDelivery + └─ DeliveryAttempt + └─ ProviderEvent (append-only) + └─ channel projector + └─ SubmissionOutcome / DeliveryOutcome / EvidenceLevel + + EngagementFacts + SuppressionFacts +``` + +Four identities, four lifecycles. A logical request is not a recipient job, a recipient job is not a +provider attempt, and a provider attempt is not the event stream that describes it. + +## Why not one status enum + +A single linear status would have to answer "what happened?" with one value, and the real answers do +not fit on one line: + +- An email can be `DELIVERED` and then generate a complaint. Both facts are true and both matter: + one for reporting, the other for suppression. +- Twilio does not guarantee callback ordering, so `sent` routinely arrives after `delivered`. Under + an ordinal rule the later, weaker event silently overwrites the stronger one. +- APNs may accept a notification and then store, replace or discard it. + +So the ledger stores events and a channel projector merges them through an explicit transition table. +`StandardDeliveryProjector` holds the shared rules; provider projectors add only their own event +vocabulary. + +## Merge rules + +| Transition | Result | +|---|---| +| `sent` → `delivered` | applied | +| `delivered` → `sent` | ignored, event still stored | +| `delivered` → `complaint` | complaint fact added, delivery preserved | +| `complaint` → `delivered` | delivery applied, complaint preserved | +| `accepted` → `bounced` | applied | +| `read` → `displayed` | ignored | +| hard bounce → `delivered` | ignored, hard bounce is terminal | + +Engagement (`opened`, `clicked`) is stored beside the delivery outcome and never changes it. + +## Ambiguity + +```text +platform ──── send ────▶ provider + │ + └── accepted + ✗ connection reset +``` + +The platform may hold no provider request id while the notification really was sent. The attempt +records `requestStarted`, `requestBodyCommitted`, `providerResponseReceived` and an +`EvidenceCertainty` for each, so a later decision can tell "we know nothing was sent" apart from "we +could not read the answer". + +`ProviderSubmissionResult` enforces this: an ambiguous result may not claim `PROVIDER_ACCEPTED`, and +no submission result of any kind may carry a delivery outcome. diff --git a/docs/notification/migration-guide.md b/docs/notification/migration-guide.md new file mode 100644 index 00000000..61dd23e3 --- /dev/null +++ b/docs/notification/migration-guide.md @@ -0,0 +1,31 @@ +# Migration guide + +## From the R0 routing seam + +The pre-existing `dev.caskeleton.adapter.outbound.notification` router (`RoutingNotifier`, +`FailOpenNotificationProvider`, the Google email and Slack webhook seams) stays untouched. The +delivery platform lives beside it under `…notification.platform` and does not modify or delete any +R0 class. + +Migration order per capability: + +1. Register the contact points behind `ContactPointStorePort` so the platform owns protected values. +2. Publish the template version, and pin the template id, version and locale at every call site. +3. Move the call site from the router to the N1 typed facade for the channel. +4. Verify evidence in the snapshot rather than in the caller's return value: `submit()` is durable + acceptance and nothing more. +5. Remove the R0 route only after the platform route has produced provider evidence in the target + environment. + +## Return-value semantics change + +The R0 seam returned a send-shaped result. `NotificationReceipt` returns `notificationId`, a request +status and an acceptance time. Callers that treated the old return value as proof of delivery must be +changed; there is no compatibility shim, because a shim would have to invent the delivery claim this +platform exists to avoid. + +## FCM target migration + +Registration tokens keep working through `LegacyFcmRegistrationToken`. New registrations should use +`FcmInstallationId`. The two are distinct types, so a migration is a compile-time task rather than a +runtime guess. diff --git a/docs/notification/module-mapping.md b/docs/notification/module-mapping.md new file mode 100644 index 00000000..0eb882c1 --- /dev/null +++ b/docs/notification/module-mapping.md @@ -0,0 +1,94 @@ +# Notification Delivery Platform — module mapping + +> Source design: `notification-superpowers-package/docs/superpowers/specs/2026-08-10-notification-platform-design.md` +> +> Source plan: `notification-superpowers-package/docs/superpowers/plans/2026-08-10-notification-platform-implementation-plan.md` + +## Why a mapping exists + +The plan was written against a hypothetical repository (`modules/notification/**`, root package +`io.backend.skeleton.notification`, 31 Gradle projects). This repository is a fail-closed +19-leaf Clean Architecture template: `src/settings.gradle` rejects any registry that does not +contain exactly the 19 modules in `src/config/architecture/modules.json`, and +`verifyCleanArchitectureDependencies` rejects any project edge outside `allowed_dependencies`. + +Creating 31 new Gradle projects would violate HARD-STOP #5 of `AGENTS.md`. The package README +anticipates this and instructs the implementer to map dependency catalog and package/file paths onto +the host repository's rules while preserving the public contracts and reliability semantics. + +Every logical module of the plan is therefore implemented as a **package** inside the registered leaf +that owns its responsibility. No public contract, evidence rule, or reliability semantic is dropped. + +## Logical module → registered leaf + +| Plan module | Registered leaf | Package | +|---|---|---| +| `notification-core-api` | `application-core` | `dev.caskeleton.application.notification.platform.api` | +| `notification-content-api` | `application-core` | `…platform.api.content` | +| `notification-contact-api` | `application-core` | `…platform.contact` | +| `notification-template-api` | `application-core` | `…platform.template` | +| `notification-policy` | `application-core` | `…platform.policy` | +| `notification-provider-spi` | `application-core` | `…platform.provider` | +| `notification-callback-api` | `application-core` | `…platform.callback` | +| `notification-email-api` | `application-core` | `…platform.email` | +| `notification-sms-api` | `application-core` | `…platform.sms` | +| `notification-push-api` | `application-core` | `…platform.push` | +| `notification-webpush` (API half) | `application-core` | `…platform.webpush` | +| `notification-inbox-api` | `application-core` | `…platform.inbox` | +| `notification-admin-api` | `application-core` | `…platform.admin` | +| `notification-security` (ports + redaction) | `application-core` | `…platform.security` | +| `notification-observability` (ports) | `application-core` | `…platform.observation` | +| `notification-dispatch-runtime` | `adapter:outbound:notification` | `dev.caskeleton.adapter.outbound.notification.platform.dispatch` | +| `notification-security` (AES-GCM/HMAC impl) | `adapter:outbound:notification` | `…platform.security` | +| `notification-template-thymeleaf` (reference renderer) | `adapter:outbound:notification` | `…platform.template` | +| `notification-email-smtp` | `adapter:outbound:notification` | `…platform.provider.smtp` | +| `notification-email-ses` | `adapter:outbound:notification` | `…platform.provider.ses` | +| `notification-sms-twilio` | `adapter:outbound:notification` | `…platform.provider.twilio` | +| `notification-push-fcm` | `adapter:outbound:notification` | `…platform.provider.fcm` | +| `notification-push-apns` | `adapter:outbound:notification` | `…platform.provider.apns` | +| `notification-webpush` (transport + crypto) | `adapter:outbound:notification` | `…platform.provider.webpush` | +| `notification-webhook-extension` | `adapter:outbound:notification` | `…platform.provider.webhook` | +| `notification-observability` (Micrometer impl) | `adapter:outbound:notification` | `…platform.observation` | +| `notification-admin-runtime` | `adapter:outbound:notification` | `…platform.admin` | +| `notification-reactor` | `adapter:outbound:notification` | `…platform.reactor` | +| `notification-spring-boot-starter` | `adapter:outbound:notification` (+ `app-bootstrap` wiring) | `…platform.autoconfigure` | +| `notification-persistence-jpa` | `adapter:outbound:persistence-jpa` | `dev.caskeleton.adapter.outbound.persistence.notification.platform` | +| `notification-inbox-jpa` | `adapter:outbound:persistence-jpa` | `…persistence.notification.platform.inbox` | +| `notification-callback-mvc` | `adapter:inbound:web` | `dev.caskeleton.adapter.inbound.web.notification.platform.callback` | +| `notification-callback-webflux` | `adapter:inbound:web` | `…callback.reactive` | +| `notification-testkit` | test source sets of the owning leaves | `…platform.testkit` | + +## Dependency-direction consequences + +The plan's module DAG (`*-api` → `provider-spi`/`policy` → runtime/adapters → starter) is preserved +by the leaf DAG that the registry already enforces: + +```text +application-core (all *-api, provider SPI, policy, callback contracts) + ↑ ↑ ↑ +adapter:outbound:notification adapter:outbound:persistence-jpa adapter:inbound:web + ↑ ↑ ↑ + app-bootstrap +``` + +Two plan edges cannot be expressed as project edges in this repository, and are replaced by ports: + +1. `notification-email-ses`, `notification-sms-twilio`, `notification-push-fcm`, + `notification-push-apns`, `notification-webpush`, `notification-webhook-extension` + → `httpclient platform`. + `adapter-outbound-notification` is not allowed to depend on `adapter-outbound-httpclient`. + The provider adapters therefore call + `dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway`, + an adapter-local port with a JDK `java.net.http.HttpClient` default implementation. + `app-bootstrap` sees both leaves and is the supported place to substitute an implementation backed + by the HTTP Client Platform (TLS/timeout/circuit-breaker/SSRF/dynamic-target policy reuse). +2. `notification-inbox-jpa` → `optional messaging outbox integration`. + `adapter-outbound-persistence-jpa` may not depend on `adapter-outbound-messaging`; the inbox + publishes through the existing persistence outbox tables plus the + `NotificationInboxSignalPort` application port, and `app-bootstrap` binds the relay. + +## Commit policy + +`AGENTS.md` pins commit policy to `human-only`. Step 5 (`git add` / `git commit`) of every plan task +is therefore intentionally **not** executed by the agent; the working tree carries the change and the +human owner commits. diff --git a/docs/notification/operations.md b/docs/notification/operations.md new file mode 100644 index 00000000..c817c9d2 --- /dev/null +++ b/docs/notification/operations.md @@ -0,0 +1,47 @@ +# Operations + +## Runtime shape + +```text +durable queue (PostgreSQL, FOR UPDATE SKIP LOCKED) + → expiry check + → suppression and eligibility re-check + → provider health gate + → rate limiter + → concurrency limiter + → provider adapter +``` + +Provider calls run outside every database transaction. The attempt row is committed first, so after a +crash the row is either absent (nothing was sent) or present in `DISPATCHING` (reconciliation has +something to ask about). + +## Guards that exist for specific incidents + +| Guard | The incident it prevents | +|---|---| +| Credential failure opens the provider route | One expired key multiplied by a queue becomes a self-inflicted outage | +| Retry budget per provider profile | A provider outage turning every queued notification into its own retry loop | +| Ambiguous attempts block automatic fallback | A push whose response was lost arriving alongside the "just in case" SMS | +| Permits released during backoff | A slow provider pinning the whole concurrency budget on work that is only waiting | +| Bounded drain on rotation | A provider that never answers holding a credential rotation open forever | +| Fail-fast intake on capacity | An unbounded in-memory queue absorbing a burst it cannot survive | + +## Scheduling + +`scheduleAt` activates the job, `notBefore` is the earliest permitted provider submission, and +`expiresAt` blocks new attempts, retries and fallbacks. Suppression and expiry are re-checked +immediately before dispatch, because a scheduled notification can sit in the queue for hours and the +user may have opted out in the meantime. + +## Redrive + +A redrive preserves `NotificationId` and `RecipientDeliveryId`, creates a new `DeliveryAttemptId`, and +reuses the pinned template version and rendered digest. Sending different content is a new +notification, not a redrive. Redriving an ambiguous attempt requires explicit duplicate-risk approval, +because the platform genuinely cannot tell whether the first submission reached the user. + +## Actuator surface + +Provider runtime states and generations, queue depth and age, callback and reconciliation health. +Never addresses, never credentials. diff --git a/docs/notification/provider-runbooks.md b/docs/notification/provider-runbooks.md new file mode 100644 index 00000000..6c2092e0 --- /dev/null +++ b/docs/notification/provider-runbooks.md @@ -0,0 +1,65 @@ +# Provider runbooks + +## SMTP + +| Symptom | Classification | Action | +|---|---|---| +| Final `2xx` after `DATA` | `CONFIRMED_ACCEPTED` / `PROVIDER_ACCEPTED` | None; this is acceptance, not inbox delivery | +| `4yz` | `TRANSIENT_PROVIDER` | Retry under budget and deadline | +| `5yz` | `PERMANENT_PROVIDER` or `INVALID_RECIPIENT` | Stop, or invalidate the contact point | +| Connection lost after `DATA` | `AMBIGUOUS_SUBMISSION` | Reconcile or escalate; do not resend automatically | + +Connection, read, write and pool-acquire timeouts are all finite. There is no unbounded timeout. + +## Amazon SES + +`MessageId` is acceptance evidence. SES itself documents that it can accept a request and then not +send, so `MessageId` is never mapped to `DELIVERED`. + +| Event | Normalized | +|---|---| +| `Send` | reinforces `PROVIDER_ACCEPTED` | +| `Delivery` | `DELIVERY_CONFIRMED` / `NETWORK_OR_CARRIER_ACCEPTED` | +| `DeliveryDelay` | delay fact | +| `Bounce` (permanent) | `BOUNCED_HARD` plus hard-bounce suppression | +| `Bounce` (transient) | `BOUNCED_SOFT`; retry policy input, not a suppression reason | +| `Complaint` | complaint fact plus suppression | +| `Reject` | `PROVIDER_REJECTED` | +| `RenderingFailure` | `TEMPLATE_FAILURE` | + +## Twilio + +`accepted`/`queued` is acceptance only. `sent` is carrier acceptance. `delivered` is device delivery. + +Callbacks are not ordered. A `sent` arriving after `delivered` is stored and ignored by the +projection. Missing callbacks are corrected by status polling under the provider rate limit. + +Signature verification uses the canonical external URL from the profile, not the URL the servlet +container reconstructed behind a proxy. + +## FCM + +| Error | Classification | +|---|---| +| `UNREGISTERED` | `INVALID_RECIPIENT`; invalidate the contact point, never retry | +| `INVALID_ARGUMENT` | `INVALID_PAYLOAD` | +| `QUOTA_EXCEEDED` | `THROTTLED`, exponential backoff | +| `UNAVAILABLE` | `TRANSIENT_PROVIDER`, honour `Retry-After`, add jitter | +| Credential failure | `AUTHENTICATION`; opens the provider route | + +A batch is one transport call and many attempts. Partial results map back by input index; one +transport failure does not become one shared outcome unless the adapter can prove it. + +## APNs + +2xx is acceptance. Environment and topic mismatches are configuration failures, not delivery +failures. Sandbox and production tokens are separate namespaces. + +## Web Push + +`TTL` is mandatory by protocol. `201` is acceptance. `404` is an expired subscription per RFC 8030; +provider-documented `410` maps the same way. Payloads use `aes128gcm` per RFC 8291 and VAPID JWTs are +signed per RFC 8292 with the audience taken from the endpoint origin. + +VAPID key rotation is not ordinary credential rotation: a restricted subscription may need to be +re-created, so it is a migration operation. diff --git a/docs/notification/security-privacy.md b/docs/notification/security-privacy.md new file mode 100644 index 00000000..cf068210 --- /dev/null +++ b/docs/notification/security-privacy.md @@ -0,0 +1,56 @@ +# Security and privacy + +## Protected values + +Email addresses, phone numbers, FCM installation ids and legacy tokens, APNs device tokens, Web Push +endpoints and keys, VAPID private keys, provider credentials, callback signing secrets, template +variables, rendered bodies, attachment references and unsubscribe tokens. + +## At rest + +Contact points are encrypted with AES-256-GCM. Equality lookup uses a separate HMAC-SHA-256 +fingerprint. + +Two keys, not one, because the requirements are opposite: the ciphertext must be non-deterministic so +two records of the same address are not visibly identical, while equality lookup must be +deterministic. The fingerprint is keyed rather than a plain digest because phone numbers and email +addresses come from a small, enumerable space — an unkeyed hash of a phone number is recoverable in +seconds. + +The contact point kind is bound into the GCM associated data, so a ciphertext cannot be moved between +contact kinds without failing the authentication tag. + +An unknown key id is refused rather than silently falling back to the current key: a silent fallback +would turn every historical row into a tag failure at read time. + +## Never logged, never a metric tag + +Addresses, tokens, Web Push endpoints and keys, message bodies, template variables, provider +credentials, unsubscribe tokens, attachment URLs, raw callback payloads and raw provider request ids. + +Two mechanisms enforce this rather than convention: + +- `CardinalityGuard` validates every metric tag against a closed allowlist. +- `SafeDiagnosticContext` rejects any structured-diagnostic field outside its allowlist. + +An allowlist rather than a denylist, because the failure mode of a denylist is that the one field +nobody thought of is the one that leaks. + +Every contact point value type overrides `toString()` to print `[redacted]`. That covers the case a +central redactor cannot: a value interpolated into a log line by accident. + +## Web Push endpoints + +RFC 8030 defines the push URI as a capability URL — knowing it is sufficient to push to the +subscriber. It is handled as a secret, not as a URL. + +## Callbacks + +TLS, provider signature verification over the exact received bytes and external URL, replay defence +where a timestamp or nonce is available, body-size and content-type limits, profile binding, rate +limiting, idempotent ingestion and a security audit trail for rejections. + +## Tenant isolation + +Every store port carries the tenant boundary in its signature. Administrative operations require an +explicit tenant or a global authority. diff --git a/docs/notification/support-matrix.md b/docs/notification/support-matrix.md new file mode 100644 index 00000000..0e97a725 --- /dev/null +++ b/docs/notification/support-matrix.md @@ -0,0 +1,68 @@ +# Notification support matrix + +What each channel can actually prove, and what the platform refuses to claim. + +## Channels + +| Channel | Reference implementation | Grade | Strongest evidence the platform records by default | +|---|---|---|---| +| Email | SMTP, Amazon SES API | Stable | Provider acceptance; recipient mail-server delivery, bounce and complaint when the provider publishes events | +| SMS | Twilio Programmable Messaging | Stable | `accepted`/`queued`, `sent`, and carrier-DLR `delivered`/`undelivered` | +| Mobile push (Android and cross-platform) | FCM, FID-first with legacy registration token compatibility | Stable | FCM acceptance and explicit failures | +| Mobile push (Apple) | APNs HTTP/2 provider API | Stable | APNs acceptance | +| Web Push | RFC 8030, RFC 8291, RFC 8292 | Stable | Push-service acceptance; user-agent acknowledgement only where the service offers receipts | +| In-app inbox | Own database | Optional stable | `PERSISTED`, `SEEN`, `READ` | +| Webhook | HTTP client platform | Extension | Whatever the receiving HTTP contract states | + +## Evidence levels + +`NONE` → `PLATFORM_QUEUED` → `PROVIDER_ACCEPTED` → `NETWORK_OR_CARRIER_ACCEPTED` → +`DEVICE_DELIVERED` → `USER_AGENT_DISPLAYED` → `USER_READ` + +| Provider signal | Highest evidence it may produce | +|---|---| +| Internal queue commit | `PLATFORM_QUEUED` | +| SES `MessageId` | `PROVIDER_ACCEPTED` | +| SES `Delivery` | `NETWORK_OR_CARRIER_ACCEPTED` | +| Twilio `accepted` / `queued` | `PROVIDER_ACCEPTED` | +| Twilio `sent` | `NETWORK_OR_CARRIER_ACCEPTED` | +| Twilio `delivered` | `DEVICE_DELIVERED` | +| FCM send success | `PROVIDER_ACCEPTED` | +| APNs 2xx | `PROVIDER_ACCEPTED` | +| Web Push `201` | `PROVIDER_ACCEPTED` | +| Web Push receipt capability | `DEVICE_DELIVERED` | +| In-app row commit | `PROVIDER_ACCEPTED` | +| In-app `seen` endpoint | `USER_AGENT_DISPLAYED` | +| In-app `read` endpoint, authenticated app receipt | `USER_READ` | + +Promotions the platform will not make, in code or in configuration: + +- FCM send success is not `DEVICE_DELIVERED`. +- An APNs 2xx is not `DELIVERED`. +- An SES `MessageId` is not `DELIVERED`. +- An SMTP `250` is not inbox delivery. + +## Submission outcomes + +`NOT_SUBMITTED`, `CONFIRMED_ACCEPTED`, `CONFIRMED_REJECTED`, `AMBIGUOUS`. + +`AMBIGUOUS` is a first-class stored state, not an error path. It means the request body was committed +to the provider and the outcome could not be read. While an ambiguous attempt exists on a recipient +delivery, automatic retry and automatic cross-channel fallback are both blocked. + +## Not supported + +The platform will not claim any of the following, because no channel above can support them: + +- guaranteed delivery +- guaranteed read +- exactly-once human notification +- unconditional multi-provider failover after an unread response +- provider SDK types in the public API +- audience selection, campaign segmentation or jurisdiction rulings + +## Target model + +`FCM_FID` is the primary mobile push target. `FCM_REGISTRATION_TOKEN_LEGACY` and +`APNS_DEVICE_TOKEN` are separate types with separate lifecycles; they are never flattened into one +string field. diff --git a/infra/notification/toxiproxy/docker-compose.yml b/infra/notification/toxiproxy/docker-compose.yml new file mode 100644 index 00000000..ac103cad --- /dev/null +++ b/infra/notification/toxiproxy/docker-compose.yml @@ -0,0 +1,94 @@ +# Toxiproxy fault injection for the notification delivery platform. +# +# Scope, stated up front: this is the *nightly and release* fault suite, not the PR gate. The PR +# suite runs against a loopback socket harness in-process — deterministic, no Docker, no provider +# sandbox — because a gate that needs infrastructure is a gate people learn to skip. What lives +# here are the faults that harness cannot produce: real TCP behaviour under latency, bandwidth +# starvation, and connection resets at a point the JVM's own socket layer decides. +# +# Usage: +# docker compose -f infra/notification/toxiproxy/docker-compose.yml up -d +# ./gradlew :adapter:outbound:notification:test -Dnotification.faultProxy=http://127.0.0.1:8474 +# +# The proxies below front *stub* upstreams, never a provider's real API. Pointing a toxic proxy at +# a live provider sends real notifications to real people from a test run, and adds a rate-limit +# incident on an account the team shares. +services: + toxiproxy: + image: ghcr.io/shopify/toxiproxy:2.11.0 + container_name: notification-toxiproxy + ports: + - "8474:8474" # control API + - "18081:18081" # -> ses-stub + - "18082:18082" # -> twilio-stub + - "18083:18083" # -> push-stub (APNs / FCM / Web Push) + networks: [notification-fault] + healthcheck: + test: ["CMD", "/toxiproxy-cli", "list"] + interval: 5s + timeout: 3s + retries: 10 + + # Deterministic upstreams. Each returns the provider's success shape and nothing else; the + # interesting behaviour is injected by the proxy in front of it, not by the stub. + ses-stub: + image: mendhak/http-https-echo:35 + environment: + HTTP_PORT: "8080" + networks: [notification-fault] + + twilio-stub: + image: mendhak/http-https-echo:35 + environment: + HTTP_PORT: "8080" + networks: [notification-fault] + + push-stub: + image: mendhak/http-https-echo:35 + environment: + HTTP_PORT: "8080" + networks: [notification-fault] + + # Creates the proxies and the toxics once the control API is up. Kept as a job rather than a + # README step so the topology is reproducible and reviewable rather than typed from memory. + provision: + image: ghcr.io/shopify/toxiproxy:2.11.0 + depends_on: + toxiproxy: + condition: service_healthy + networks: [notification-fault] + entrypoint: + - /bin/sh + - -c + - | + set -e + CLI="/toxiproxy-cli -h toxiproxy:8474" + $$CLI create -l 0.0.0.0:18081 -u ses-stub:8080 ses + $$CLI create -l 0.0.0.0:18082 -u twilio-stub:8080 twilio + $$CLI create -l 0.0.0.0:18083 -u push-stub:8080 push + + # Response loss after the request was committed: the provider received and acted on the + # message, and the answer never came back. This is the AMBIGUOUS case, and it is the one + # fault no provider's documentation describes. + $$CLI toxic add -t timeout -a timeout=0 -n response_loss --downstream --toxicity 0 ses + $$CLI toxic add -t timeout -a timeout=0 -n response_loss --downstream --toxicity 0 twilio + $$CLI toxic add -t timeout -a timeout=0 -n response_loss --downstream --toxicity 0 push + + # Latency past the adapter's own timeout, to prove the timeout is the adapter's decision + # rather than the socket's. + $$CLI toxic add -t latency -a latency=8000 -n slow --toxicity 0 ses + $$CLI toxic add -t latency -a latency=8000 -n slow --toxicity 0 twilio + $$CLI toxic add -t latency -a latency=8000 -n slow --toxicity 0 push + + # Partial write: the connection dies mid-body. Distinct from response loss, because the + # provider never got a complete request and the attempt is genuinely retryable. + $$CLI toxic add -t limit_data -a bytes=64 -n partial_write --upstream --toxicity 0 ses + $$CLI toxic add -t limit_data -a bytes=64 -n partial_write --upstream --toxicity 0 twilio + $$CLI toxic add -t limit_data -a bytes=64 -n partial_write --upstream --toxicity 0 push + + echo "proxies ready; toxics are registered at toxicity=0 and enabled per test" + $$CLI list + +networks: + notification-fault: + driver: bridge diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackMvcSecurityConfiguration.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackMvcSecurityConfiguration.java new file mode 100644 index 00000000..74a10d7c --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackMvcSecurityConfiguration.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.inbound.web.notification.platform.callback; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Security chain for the provider callback endpoints. + * + *

Callbacks authenticate with a provider signature, not with a user session, so they get their + * own chain: CSRF and session creation are off, and the ordinary user chain never sees them. + * Putting them on the user chain would either break every provider or force the user chain to be + * permissive. + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty( + prefix = "ca-skeleton.notification.platform.callbacks", + name = "enabled", + havingValue = "true") +public class CallbackMvcSecurityConfiguration { + + /** Dedicated, ordered-first chain for the callback path. */ + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE + 10) + public SecurityFilterChain notificationCallbackFilterChain(HttpSecurity http) throws Exception { + return http.securityMatcher("/internal/notification/callbacks/**") + .csrf(csrf -> csrf.disable()) + .sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(requests -> requests.anyRequest().permitAll()) + .build(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackRequestFactory.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackRequestFactory.java new file mode 100644 index 00000000..5d237b1d --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackRequestFactory.java @@ -0,0 +1,75 @@ +package dev.caskeleton.adapter.inbound.web.notification.platform.callback; + +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.callback.CallbackRequest; +import jakarta.servlet.http.HttpServletRequest; +import java.time.Clock; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Builds the transport-neutral callback request. + * + *

Both the servlet and reactive endpoints use this, so signature verification sees exactly the + * same canonical bytes and URL regardless of which stack received the call. + */ +public final class CallbackRequestFactory { + + private final ExternalRequestUrlResolver urlResolver; + private final Clock clock; + + public CallbackRequestFactory(ExternalRequestUrlResolver urlResolver, Clock clock) { + this.urlResolver = Objects.requireNonNull(urlResolver, "urlResolver"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + /** Build from a servlet request plus the already-read raw body. */ + public CallbackRequest create( + String provider, String profile, HttpServletRequest request, byte[] body) { + Objects.requireNonNull(provider, "provider"); + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(body, "body"); + + Map> headers = new LinkedHashMap<>(); + for (String name : Collections.list(request.getHeaderNames())) { + headers.put(name, new ArrayList<>(Collections.list(request.getHeaders(name)))); + } + + return new CallbackRequest( + new ProviderId(provider), + new ProviderProfileId(profile), + urlResolver.resolve(request), + request.getMethod(), + Optional.ofNullable(request.getContentType()), + headers, + body, + clock.instant()); + } + + /** Build from an already-resolved external URL, used by the reactive endpoint. */ + public CallbackRequest create( + String provider, + String profile, + String externalUrl, + String method, + Optional contentType, + Map> headers, + byte[] body) { + return new CallbackRequest( + new ProviderId(provider), + new ProviderProfileId(profile), + externalUrl, + method, + contentType, + headers, + body, + clock.instant()); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/ExternalRequestUrlResolver.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/ExternalRequestUrlResolver.java new file mode 100644 index 00000000..c59a022a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/ExternalRequestUrlResolver.java @@ -0,0 +1,71 @@ +package dev.caskeleton.adapter.inbound.web.notification.platform.callback; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** + * Reconstructs the URL the provider actually called. + * + *

Several providers sign the request URL, so getting this wrong turns every valid webhook into a + * signature failure. Forwarded headers are only honoured when the immediate peer is a configured + * trusted proxy: trusting them unconditionally would let any caller choose the URL that gets + * verified, which defeats the signature entirely. + */ +public final class ExternalRequestUrlResolver { + + private final Set trustedProxies; + + public ExternalRequestUrlResolver(Set trustedProxies) { + this.trustedProxies = Set.copyOf(Objects.requireNonNull(trustedProxies, "trustedProxies")); + } + + /** External URL of a request. */ + public String resolve(HttpServletRequest request) { + Objects.requireNonNull(request, "request"); + String scheme = request.getScheme(); + String host = request.getServerName(); + int port = request.getServerPort(); + + if (trustedProxies.contains(request.getRemoteAddr())) { + String forwarded = request.getHeader("Forwarded"); + if (forwarded != null) { + for (String element : forwarded.split(";", -1)) { + String trimmed = element.trim().toLowerCase(Locale.ROOT); + if (trimmed.startsWith("proto=")) { + scheme = trimmed.substring("proto=".length()); + } else if (trimmed.startsWith("host=")) { + host = element.trim().substring("host=".length()); + port = -1; + } + } + } else { + String protoHeader = request.getHeader("X-Forwarded-Proto"); + String hostHeader = request.getHeader("X-Forwarded-Host"); + if (protoHeader != null) { + scheme = protoHeader; + } + if (hostHeader != null) { + host = hostHeader; + port = -1; + } + } + } + + StringBuilder url = new StringBuilder(scheme).append("://").append(host); + boolean defaultPort = + port < 0 + || ("https".equalsIgnoreCase(scheme) && port == 443) + || ("http".equalsIgnoreCase(scheme) && port == 80); + if (!defaultPort) { + url.append(':').append(port); + } + url.append(request.getRequestURI()); + String query = request.getQueryString(); + if (query != null && !query.isBlank()) { + url.append('?').append(query); + } + return url.toString(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcController.java new file mode 100644 index 00000000..bf72ee64 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcController.java @@ -0,0 +1,75 @@ +package dev.caskeleton.adapter.inbound.web.notification.platform.callback; + +import dev.caskeleton.application.notification.platform.api.error.CallbackValidationException; +import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Objects; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Servlet callback endpoint. + * + *

The body arrives as raw bytes, never as a parsed form. Providers sign the exact octets, and + * letting the container parse and re-encode them is the most common cause of a valid webhook + * failing verification. + * + *

The response is a bare {@code 204}: no body, no diagnostics. A provider only needs to know the + * event is recorded, and an error body would be a channel for leaking what the platform knows. + * + *

Registered only in a servlet application and only when callbacks are enabled. An annotated + * controller is also honoured by WebFlux, so without the servlet condition a reactive deployment + * would map both this and the functional router onto the same path — and a provider signature would + * then be verified twice against two different canonical URLs. + */ +@RestController +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnProperty( + prefix = "ca-skeleton.notification.platform.callbacks", + name = "enabled", + havingValue = "true") +@RequestMapping("/internal/notification/callbacks") +public final class NotificationCallbackMvcController { + + /** Hard body ceiling applied before any provider adapter is consulted. */ + public static final int MAX_BODY_BYTES = 65_536; + + private final ProviderCallbackIngestionService ingestion; + private final CallbackRequestFactory requestFactory; + + public NotificationCallbackMvcController( + ProviderCallbackIngestionService ingestion, CallbackRequestFactory requestFactory) { + this.ingestion = Objects.requireNonNull(ingestion, "ingestion"); + this.requestFactory = Objects.requireNonNull(requestFactory, "requestFactory"); + } + + /** Receive one provider callback. */ + @PostMapping(path = "/{provider}/{profile}") + public ResponseEntity callback( + @PathVariable String provider, + @PathVariable String profile, + HttpServletRequest request, + @RequestBody byte[] body) { + if (body.length > MAX_BODY_BYTES) { + return ResponseEntity.status(HttpStatus.CONTENT_TOO_LARGE).build(); + } + // A duplicate answers 204 exactly like a first delivery. The provider did its job either way, + // and any other status would make it retry an event that is already recorded. + ingestion.ingest(requestFactory.create(provider, profile, request, body)); + return ResponseEntity.noContent().build(); + } + + /** A rejected callback never reveals why beyond the status code. */ + @ExceptionHandler(CallbackValidationException.class) + public ResponseEntity onValidationFailure(CallbackValidationException failure) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/BoundedCallbackBodyReader.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/BoundedCallbackBodyReader.java new file mode 100644 index 00000000..f62c1744 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/BoundedCallbackBodyReader.java @@ -0,0 +1,48 @@ +package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive; + +import java.util.Objects; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.web.reactive.function.server.ServerRequest; +import reactor.core.publisher.Mono; + +/** + * Reads the raw body with a hard ceiling and no buffer leaks. + * + *

Every {@link DataBuffer} is released on success, on error and on cancellation. A reactive + * endpoint that forgets the cancellation path leaks native memory exactly when it is under the load + * that caused the cancellation. + */ +public final class BoundedCallbackBodyReader { + + private final int maxBytes; + + public BoundedCallbackBodyReader(int maxBytes) { + if (maxBytes < 1) { + throw new IllegalArgumentException("maxBytes"); + } + this.maxBytes = maxBytes; + } + + /** Read at most the configured number of bytes. */ + public Mono read(ServerRequest request) { + Objects.requireNonNull(request, "request"); + return DataBufferUtils.join(request.bodyToFlux(DataBuffer.class), maxBytes) + .map( + buffer -> { + try { + byte[] bytes = new byte[buffer.readableByteCount()]; + buffer.read(bytes); + return bytes; + } finally { + DataBufferUtils.release(buffer); + } + }) + .defaultIfEmpty(new byte[0]); + } + + /** Configured ceiling. */ + public int maxBytes() { + return maxBytes; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/CallbackWebFluxConfiguration.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/CallbackWebFluxConfiguration.java new file mode 100644 index 00000000..f0f00546 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/CallbackWebFluxConfiguration.java @@ -0,0 +1,58 @@ +package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive; + +import dev.caskeleton.adapter.inbound.web.notification.platform.callback.CallbackRequestFactory; +import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerResponse; + +/** + * Registers the reactive callback transport, and only it. + * + *

This configuration is {@code REACTIVE}-only and the servlet controller carries the matching + * {@code SERVLET} condition, so exactly one of the two is ever registered — by construction rather + * than by convention. Both on the same path would mean a provider signature is verified twice + * against two different canonical URLs, a failure that shows up only in production and only for + * signed providers, and reads like a credential problem. + * + *

The body ceiling is read as a property rather than through the platform settings type: that + * type belongs to the outbound notification adapter, which this inbound adapter must not depend on. + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) +@ConditionalOnProperty( + prefix = "ca-skeleton.notification.platform.callbacks", + name = "enabled", + havingValue = "true") +public class CallbackWebFluxConfiguration { + + /** Bounded body reader; the ceiling applies before any provider adapter is consulted. */ + @Bean + @ConditionalOnMissingBean + public BoundedCallbackBodyReader notificationCallbackBodyReader( + @Value("${ca-skeleton.notification.platform.callbacks.max-body-bytes:65536}") int maxBytes) { + return new BoundedCallbackBodyReader(maxBytes); + } + + /** Reactive handler. */ + @Bean + @ConditionalOnMissingBean + public NotificationCallbackWebFluxHandler notificationCallbackWebFluxHandler( + ProviderCallbackIngestionService ingestion, + CallbackRequestFactory requestFactory, + BoundedCallbackBodyReader bodyReader) { + return new NotificationCallbackWebFluxHandler(ingestion, requestFactory, bodyReader); + } + + /** Functional route for the callback path. */ + @Bean + public RouterFunction notificationCallbackRoutes( + NotificationCallbackWebFluxHandler handler) { + return new CallbackWebFluxRouter(handler).routes(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/CallbackWebFluxRouter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/CallbackWebFluxRouter.java new file mode 100644 index 00000000..cdabab9d --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/CallbackWebFluxRouter.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive; + +import java.util.Objects; +import org.springframework.web.reactive.function.server.RequestPredicates; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; + +/** + * Routes the reactive callback path. + * + *

Kept separate from the servlet controller so that only one of the two is ever registered; two + * endpoints on the same path would mean a provider's signature is verified twice against two + * different canonical URLs. + */ +public final class CallbackWebFluxRouter { + + private final NotificationCallbackWebFluxHandler handler; + + public CallbackWebFluxRouter(NotificationCallbackWebFluxHandler handler) { + this.handler = Objects.requireNonNull(handler, "handler"); + } + + /** Router function for the callback path. */ + public RouterFunction routes() { + return RouterFunctions.route( + RequestPredicates.POST("/internal/notification/callbacks/{provider}/{profile}"), + handler::handle); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/NotificationCallbackWebFluxHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/NotificationCallbackWebFluxHandler.java new file mode 100644 index 00000000..bdddbb36 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/NotificationCallbackWebFluxHandler.java @@ -0,0 +1,85 @@ +package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive; + +import dev.caskeleton.adapter.inbound.web.notification.platform.callback.CallbackRequestFactory; +import dev.caskeleton.application.notification.platform.api.error.CallbackValidationException; +import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.springframework.core.io.buffer.DataBufferLimitException; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * Reactive callback endpoint. + * + *

Ingestion is blocking — it writes to the database — so it runs on {@code boundedElastic} and + * never on the event loop. Running it inline would stall every other connection the loop is + * serving. + * + *

It shares the canonicalisation and the ingestion service with the servlet endpoint, so a + * deployment can switch web stacks without changing what a provider signature is checked against. + */ +public final class NotificationCallbackWebFluxHandler { + + private final ProviderCallbackIngestionService ingestion; + private final CallbackRequestFactory requestFactory; + private final BoundedCallbackBodyReader bodyReader; + + public NotificationCallbackWebFluxHandler( + ProviderCallbackIngestionService ingestion, + CallbackRequestFactory requestFactory, + BoundedCallbackBodyReader bodyReader) { + this.ingestion = Objects.requireNonNull(ingestion, "ingestion"); + this.requestFactory = Objects.requireNonNull(requestFactory, "requestFactory"); + this.bodyReader = Objects.requireNonNull(bodyReader, "bodyReader"); + } + + /** Handle one callback. */ + public Mono handle(ServerRequest request) { + String provider = request.pathVariable("provider"); + String profile = request.pathVariable("profile"); + + return bodyReader + .read(request) + .flatMap( + body -> + Mono.fromCallable( + () -> + ingestion.ingest( + requestFactory.create( + provider, + profile, + request.uri().toString(), + request.method().name(), + request.headers().contentType().map(Object::toString), + headers(request), + body))) + .subscribeOn(Schedulers.boundedElastic())) + .then(ServerResponse.noContent().build()) + .onErrorResume( + DataBufferLimitException.class, + failure -> ServerResponse.status(HttpStatus.CONTENT_TOO_LARGE).build()) + .onErrorResume( + CallbackValidationException.class, + failure -> ServerResponse.status(HttpStatus.BAD_REQUEST).build()); + } + + private static Map> headers(ServerRequest request) { + Map> headers = new java.util.LinkedHashMap<>(); + request + .headers() + .asHttpHeaders() + .forEach((name, values) -> headers.put(name, List.copyOf(values))); + return Map.copyOf(headers); + } + + /** Content type of a request, if declared. */ + public static Optional contentType(ServerRequest request) { + return request.headers().contentType().map(Object::toString); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcControllerTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcControllerTest.java new file mode 100644 index 00000000..0e4d3d48 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcControllerTest.java @@ -0,0 +1,396 @@ +package dev.caskeleton.adapter.inbound.web.notification.platform.callback; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId; +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.api.error.CallbackValidationException; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.callback.AppendEventResult; +import dev.caskeleton.application.notification.platform.callback.CallbackLimits; +import dev.caskeleton.application.notification.platform.callback.CallbackRequest; +import dev.caskeleton.application.notification.platform.callback.CallbackVerificationResult; +import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent; +import dev.caskeleton.application.notification.platform.callback.ProjectionResult; +import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapter; +import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapterRegistry; +import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService; +import dev.caskeleton.application.notification.platform.callback.ProviderEventLedger; +import dev.caskeleton.application.notification.platform.callback.ProviderEventProjectionService; +import dev.caskeleton.application.notification.platform.callback.ProviderEventRecord; +import dev.caskeleton.application.notification.platform.callback.ProviderEventRecordId; +import dev.caskeleton.application.notification.platform.callback.VerifiedCallback; +import dev.caskeleton.application.notification.platform.callback.VerifiedProviderEvent; +import dev.caskeleton.application.notification.platform.observation.NotificationMetricsPort; +import dev.caskeleton.application.notification.platform.observation.NotificationSecurityAuditPort; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; + +/** + * What the servlet transport is responsible for handing the callback pipeline. + * + *

The pipeline itself belongs to application-core and is tested there. What is only testable + * here is the translation: the exact received octets, the externally-visible URL, and headers that + * survive the servlet container's own casing. Each is a common cause of a valid webhook failing + * verification, and none is visible from a unit test of the provider adapter. + * + *

The capture point is the provider adapter's {@code verify}, which is the first thing in the + * pipeline to see the whole request. It rejects, so the test never needs a ledger. + */ +class NotificationCallbackMvcControllerTest { + + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-08-14T00:00:00Z"), ZoneOffset.UTC); + private static final String TRUSTED_PROXY = "10.0.0.1"; + + private final List verified = new ArrayList<>(); + private final List rejections = new ArrayList<>(); + + private final NotificationCallbackMvcController controller = + new NotificationCallbackMvcController( + new ProviderCallbackIngestionService( + new CapturingRegistry(), + new UnusedLedger(), + // Never reached: verification always fails in this fixture, and the pipeline appends + // only after a valid signature. + new ProviderEventProjectionService( + new UnusedLedger(), + providerId -> java.util.Optional.empty(), + new UnusedAttemptResolver(), + new UnusedProjectionStore(), + (attempt, facts) -> { + throw new UnsupportedOperationException(); + }, + new UnusedTransactions(), + new DiscardingMetrics()), + new UnusedPayloadProtection(), + new RecordingSecurityAudit(), + new DiscardingMetrics(), + CLOCK), + new CallbackRequestFactory(new ExternalRequestUrlResolver(Set.of(TRUSTED_PROXY)), CLOCK)); + + @Test + void theExactReceivedOctetsReachTheAdapterUnparsed() { + byte[] body = + "MessageSid=SM1&MessageStatus=delivered&Signed=a+b%2Fc".getBytes(StandardCharsets.UTF_8); + + assertThatThrownBy( + () -> + controller.callback( + "twilio", "twilio-primary", request("application/x-www-form-urlencoded"), body)) + .isInstanceOf(CallbackValidationException.class); + + // Byte for byte, including the percent-encoding a form parse would have consumed and re-encoded + // differently — which is the single most common cause of a valid webhook failing its signature. + assertThat(verified).hasSize(1); + assertThat(verified.get(0).body()).isEqualTo(body); + assertThat(verified.get(0).contentType()).contains("application/x-www-form-urlencoded"); + assertThat(verified.get(0).httpMethod()).isEqualTo("POST"); + } + + @Test + void aForwardedHostFromAnUntrustedPeerIsIgnored() { + var request = request("application/json"); + request.setRemoteAddr("203.0.113.9"); + request.addHeader("X-Forwarded-Proto", "https"); + request.addHeader("X-Forwarded-Host", "attacker.example.com"); + + assertThatThrownBy( + () -> + controller.callback( + "twilio", "twilio-primary", request, "{}".getBytes(StandardCharsets.UTF_8))) + .isInstanceOf(CallbackValidationException.class); + + // Honouring the header unconditionally would let any caller choose the URL that gets verified, + // which defeats the signature entirely. + assertThat(verified.get(0).externalUrl()).doesNotContain("attacker.example.com"); + } + + @Test + void aForwardedHostFromATrustedProxyBecomesTheCanonicalUrl() { + var request = request("application/json"); + request.setRemoteAddr(TRUSTED_PROXY); + request.addHeader("X-Forwarded-Proto", "https"); + request.addHeader("X-Forwarded-Host", "callback.example.com"); + + assertThatThrownBy( + () -> + controller.callback( + "twilio", "twilio-primary", request, "{}".getBytes(StandardCharsets.UTF_8))) + .isInstanceOf(CallbackValidationException.class); + + assertThat(verified.get(0).externalUrl()) + .isEqualTo( + "https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary"); + } + + @Test + void headersSurviveTheContainersCasingAndStayAddressableEitherWay() { + var request = request("application/json"); + request.addHeader("X-Twilio-Signature", "abc123"); + + assertThatThrownBy( + () -> + controller.callback( + "twilio", "twilio-primary", request, "{}".getBytes(StandardCharsets.UTF_8))) + .isInstanceOf(CallbackValidationException.class); + + assertThat(verified.get(0).header("x-twilio-signature")).contains("abc123"); + assertThat(verified.get(0).header("X-TWILIO-SIGNATURE")).contains("abc123"); + } + + @Test + void aBodyOverTheTransportCeilingIsRefusedBeforeAnyAdapterIsConsulted() { + byte[] oversized = new byte[NotificationCallbackMvcController.MAX_BODY_BYTES + 1]; + + var response = + controller.callback("twilio", "twilio-primary", request("application/json"), oversized); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONTENT_TOO_LARGE); + // Nothing downstream sees it, so no signature check ever runs over an attacker-sized payload. + assertThat(verified).isEmpty(); + } + + @Test + void aRejectedCallbackRevealsNothingBeyondTheStatusCode() { + var response = + controller.onValidationFailure( + new CallbackValidationException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.CALLBACK_SIGNATURE_INVALID, + FailureCategory.CALLBACK_VALIDATION_FAILURE))); + + // The endpoint is unauthenticated by design — the signature is the authentication — so an error + // body is a free oracle for whoever is probing it. + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNull(); + } + + @Test + void aRejectedSignatureIsRecordedAsASecurityEventRatherThanADeliveryEvent() { + assertThatThrownBy( + () -> + controller.callback( + "twilio", + "twilio-primary", + request("application/json"), + "{}".getBytes(StandardCharsets.UTF_8))) + .isInstanceOf(CallbackValidationException.class); + + // Writing it to the ledger would let anyone who can reach the endpoint fill a recipient's + // delivery history with noise. + assertThat(rejections).containsExactly("SIGNATURE_MISMATCH"); + } + + private static MockHttpServletRequest request(String contentType) { + var request = + new MockHttpServletRequest( + "POST", "/internal/notification/callbacks/twilio/twilio-primary"); + request.setContentType(contentType); + return request; + } + + /** Registry whose adapter records the request and then refuses it. */ + private final class CapturingRegistry implements ProviderCallbackAdapterRegistry { + + @Override + public ProviderCallbackAdapter require(ProviderProfileId profileId) { + return new ProviderCallbackAdapter() { + + @Override + public ProviderId providerId() { + return new ProviderId("twilio"); + } + + @Override + public CallbackVerificationResult verify(CallbackRequest request) { + verified.add(request); + return CallbackVerificationResult.invalid("SIGNATURE_MISMATCH"); + } + + @Override + public List normalize(VerifiedCallback callback) { + throw new UnsupportedOperationException("verification always fails in this fixture"); + } + }; + } + + @Override + public CallbackLimits limitsFor(ProviderProfileId profileId) { + return new CallbackLimits( + 65_536L, Set.of("application/json", "application/x-www-form-urlencoded")); + } + } + + /** Security audit that keeps the rejection reason. */ + private final class RecordingSecurityAudit implements NotificationSecurityAuditPort { + + @Override + public void callbackSignatureRejected(ProviderProfileId profileId, String reasonCode) { + rejections.add(reasonCode); + } + + @Override + public void callbackRejectedByLimit(ProviderProfileId profileId, String reasonCode) { + rejections.add(reasonCode); + } + } + + /** Metrics are exercised elsewhere; discarding them keeps this test about the transport. */ + private static final class DiscardingMetrics implements NotificationMetricsPort { + + @Override + public void increment(String metricName, Map tags) { + // Intentionally empty. + } + + @Override + public void record(String metricName, Map tags, Duration value) { + // Intentionally empty. + } + + @Override + public void gauge(String metricName, Map tags, double value) { + // Intentionally empty. + } + } + + /** Never reached: attempt correlation happens only for an accepted callback. */ + private static final class UnusedAttemptResolver + implements dev.caskeleton.application.notification.platform.callback + .DeliveryAttemptResolverPort { + + @Override + public java.util.Optional< + dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot> + byAttemptId(DeliveryAttemptId attemptId) { + return java.util.Optional.empty(); + } + + @Override + public java.util.Optional< + dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot> + byProviderRequestId(ProviderProfileId profileId, String providerRequestIdHash) { + return java.util.Optional.empty(); + } + } + + /** Never reached: projection runs only after a signature has been accepted. */ + private static final class UnusedProjectionStore + implements dev.caskeleton.application.notification.platform.callback + .DeliveryProjectionStorePort { + + @Override + public dev.caskeleton.application.notification.platform.callback.DeliveryProjection load( + DeliveryAttemptId attemptId) { + throw new UnsupportedOperationException(); + } + + @Override + public void save( + DeliveryAttemptId attemptId, + dev.caskeleton.application.notification.platform.callback.DeliveryProjection projection) { + throw new UnsupportedOperationException(); + } + } + + /** Never reached: nothing in this fixture gets as far as a transaction. */ + private static final class UnusedTransactions + implements dev.caskeleton.application.transaction.TransactionPort { + + @Override + public T inWrite(java.util.function.Supplier action) { + throw new UnsupportedOperationException(); + } + + @Override + public T inRootWrite(java.util.function.Supplier action) { + throw new UnsupportedOperationException(); + } + + @Override + public T inRead(java.util.function.Supplier action) { + throw new UnsupportedOperationException(); + } + + @Override + public T inNew(java.util.function.Supplier action) { + throw new UnsupportedOperationException(); + } + } + + /** Never reached: every request in this fixture is rejected before the payload is retained. */ + private static final class UnusedPayloadProtection + implements dev.caskeleton.application.notification.platform.callback + .CallbackPayloadProtectionPort { + + @Override + public byte[] protectRawPayload(byte[] rawBody) { + throw new UnsupportedOperationException(); + } + + @Override + public String digest(byte[] rawBody) { + throw new UnsupportedOperationException(); + } + + @Override + public String fingerprint( + ProviderProfileId profileId, NormalizedProviderEvent event, String rawPayloadDigest) { + throw new UnsupportedOperationException(); + } + } + + /** Never reached: every request in this fixture is rejected before the append. */ + private static final class UnusedLedger implements ProviderEventLedger { + + @Override + public AppendEventResult append(VerifiedProviderEvent event) { + throw new UnsupportedOperationException(); + } + + @Override + public AppendEventResult appendAll(List events) { + throw new UnsupportedOperationException(); + } + + @Override + public List pendingProjection(int limit) { + throw new UnsupportedOperationException(); + } + + @Override + public void markApplied(ProviderEventRecordId eventId, ProjectionResult result) { + throw new UnsupportedOperationException(); + } + + @Override + public void markFailed(ProviderEventRecordId eventId, String errorCode) { + throw new UnsupportedOperationException(); + } + + @Override + public List unmatched(int limit) { + throw new UnsupportedOperationException(); + } + + @Override + public List eventsForAttempt(DeliveryAttemptId attemptId) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/src/adapter/outbound/notification/build.gradle b/src/adapter/outbound/notification/build.gradle index 555e34ed..01671d8d 100644 --- a/src/adapter/outbound/notification/build.gradle +++ b/src/adapter/outbound/notification/build.gradle @@ -6,6 +6,34 @@ dependencies { implementation 'org.springframework.boot:spring-boot-autoconfigure' implementation 'org.springframework:spring-web' // Slack webhook client (RestClient) implementation 'org.slf4j:slf4j-api' + + // Notification Delivery Platform. + // - mail: the SMTP provider adapter is built on JavaMailSender/MimeMessageHelper, which is where + // multipart/alternative, inline resources and header validation already live. Rebuilding MIME + // by hand to avoid one dependency would be the more dangerous choice. + // - jackson-databind: provider payloads, callback bodies and the canonical variables payload are + // JSON. It stays inside this adapter; application-core never sees a JSON type. + // - reactor-core: only the optional Reactor facade uses it. The core async type stays + // CompletionStage, so nothing else on this classpath depends on Reactor. + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-json' + implementation 'io.projectreactor:reactor-core' + // JSON Schema 2020-12 validation of template variables, using the same validator and version the + // messaging adapter already depends on rather than a second implementation of the same spec. + // The YAML dataformat is excluded: schemas are supplied as JSON strings, so pulling a YAML + // parser onto the runtime classpath would add attack surface for a format nothing reads. + // Thymeleaf is the reference HTML renderer, added as the engine only — not the Spring + // starter, which would drag a view resolver and a servlet integration onto an outbound + // adapter that renders strings and never serves a request. + implementation 'org.thymeleaf:thymeleaf' + + implementation('com.networknt:json-schema-validator:3.0.2') { + exclude group: 'tools.jackson.dataformat', module: 'jackson-dataformat-yaml' + exclude group: 'com.fasterxml.jackson.dataformat', module: 'jackson-dataformat-yaml' + } + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + + testImplementation 'io.projectreactor:reactor-test' } tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } diff --git a/src/adapter/outbound/notification/gradle.lockfile b/src/adapter/outbound/notification/gradle.lockfile index 518fe132..d59942f4 100644 --- a/src/adapter/outbound/notification/gradle.lockfile +++ b/src/adapter/outbound/notification/gradle.lockfile @@ -1,23 +1,24 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.ethlo.time:itu:1.14.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath +com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath com.google.code.gson:gson:2.13.2=spotbugs com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath +com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath com.google.errorprone:error_prone_annotations:2.41.0=spotbugs com.google.errorprone:error_prone_annotations:2.47.0=checkstyle com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor @@ -32,6 +33,7 @@ com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnno com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.networknt:json-schema-validator:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle @@ -43,8 +45,11 @@ io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnota io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-test:3.8.0=testCompileClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.mail:jakarta.mail-api:2.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs @@ -53,6 +58,7 @@ net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +ognl:ognl:3.3.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.antlr:antlr4-runtime:4.13.2=checkstyle org.apache.bcel:bcel:6.12.0=spotbugs org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs @@ -60,9 +66,9 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle @@ -73,14 +79,18 @@ org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,test org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.attoparser:attoparser:2.0.7.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs +org.eclipse.angus:angus-activation:2.0.3=runtimeClasspath,testRuntimeClasspath +org.eclipse.angus:angus-mail:2.0.5=runtimeClasspath,testRuntimeClasspath org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle +org.javassist:javassist:3.29.0-GA=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath @@ -95,10 +105,10 @@ org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeCla org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath org.objenesis:objenesis:3.3=testRuntimeClasspath org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath -org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath -org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath -org.osgi:org.osgi.resource:1.0.0=testCompileClasspath -org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath org.ow2.asm:asm-analysis:9.10.1=spotbugs org.ow2.asm:asm-commons:9.10.1=spotbugs org.ow2.asm:asm-tree:9.10.1=spotbugs @@ -106,28 +116,32 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-mail:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-json:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-mail:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath @@ -137,16 +151,19 @@ org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRunti org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context-support:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.thymeleaf:thymeleaf:3.1.3.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.unbescape:unbescape:1.1.6.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath empty= diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/AdminAuthorizationGuard.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/AdminAuthorizationGuard.java new file mode 100644 index 00000000..38949c30 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/AdminAuthorizationGuard.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.notification.platform.admin; + +import dev.caskeleton.application.notification.platform.admin.AdminAccessDeniedException; +import dev.caskeleton.application.notification.platform.admin.AdminActor; +import dev.caskeleton.application.notification.platform.admin.NotificationAdminAuthority; +import dev.caskeleton.application.notification.platform.api.TenantId; +import java.util.Objects; +import java.util.Optional; + +/** + * Operator authority check. + * + *

Application authority never grants an operator authority. The two planes are separated so that + * a compromised application credential cannot redrive a message or lift a suppression — the actions + * whose whole purpose is to override the platform's own safety decisions. + */ +public final class AdminAuthorizationGuard { + + /** Require an authority, or refuse. */ + public void require(AdminActor actor, NotificationAdminAuthority authority) { + Objects.requireNonNull(actor, "actor"); + Objects.requireNonNull(authority, "authority"); + if (!actor.holds(authority)) { + throw new AdminAccessDeniedException(authority); + } + } + + /** + * Require that the actor may act on a tenant. + * + *

An actor with no tenant is a global operator; one bound to a tenant may only act inside it. + */ + public void requireTenant(AdminActor actor, TenantId tenantId) { + Objects.requireNonNull(actor, "actor"); + Objects.requireNonNull(tenantId, "tenantId"); + Optional scope = actor.tenantId(); + if (scope.isPresent() && !scope.get().equals(tenantId)) { + throw new AdminAccessDeniedException(NotificationAdminAuthority.SUPPRESS); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/DuplicateRiskGuard.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/DuplicateRiskGuard.java new file mode 100644 index 00000000..4a728134 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/DuplicateRiskGuard.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.outbound.notification.platform.admin; + +import dev.caskeleton.application.notification.platform.admin.DuplicateRiskApprovalRequiredException; +import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation; +import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot; +import java.util.Objects; + +/** + * Blocks an unapproved redrive of an ambiguous attempt. + * + *

The platform cannot tell whether the first submission reached the user, so re-sending is a + * decision with a real cost that only a human can accept. Requiring the approval flag makes that + * acceptance an explicit, audited act rather than a default. + */ +public final class DuplicateRiskGuard { + + /** Verify the operator accepted the duplicate risk when one exists. */ + public void verify(DeliveryAttemptSnapshot attempt, boolean approved) { + Objects.requireNonNull(attempt, "attempt"); + boolean risky = + attempt.confirmation() == AttemptConfirmation.AMBIGUOUS + || attempt.submissionOutcome() + == dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome + .CONFIRMED_ACCEPTED; + if (risky && !approved) { + throw new DuplicateRiskApprovalRequiredException(); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/NotificationAdminServiceImpl.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/NotificationAdminServiceImpl.java new file mode 100644 index 00000000..5bfe6d37 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/NotificationAdminServiceImpl.java @@ -0,0 +1,325 @@ +package dev.caskeleton.adapter.outbound.notification.platform.admin; + +import dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderRuntimeRegistry; +import dev.caskeleton.application.notification.platform.admin.AdminActor; +import dev.caskeleton.application.notification.platform.admin.AdminOperationResult; +import dev.caskeleton.application.notification.platform.admin.AdminOperationStorePort; +import dev.caskeleton.application.notification.platform.admin.NotificationAdminAuthority; +import dev.caskeleton.application.notification.platform.admin.NotificationAdminService; +import dev.caskeleton.application.notification.platform.admin.ReconcileCommand; +import dev.caskeleton.application.notification.platform.admin.RedriveCommand; +import dev.caskeleton.application.notification.platform.admin.SetProviderStateCommand; +import dev.caskeleton.application.notification.platform.admin.SuppressCommand; +import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId; +import dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState; +import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot; +import dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptStorePort; +import dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryStorePort; +import dev.caskeleton.application.notification.platform.dispatch.ReconciliationService; +import dev.caskeleton.application.notification.platform.observation.NotificationAuditEvent; +import dev.caskeleton.application.notification.platform.observation.NotificationAuditPort; +import dev.caskeleton.application.notification.platform.policy.SuppressionEntry; +import dev.caskeleton.application.notification.platform.policy.SuppressionId; +import dev.caskeleton.application.notification.platform.policy.SuppressionSource; +import dev.caskeleton.application.notification.platform.policy.SuppressionStorePort; +import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState; +import dev.caskeleton.application.transaction.TransactionPort; +import java.time.Clock; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +/** + * N4 operator plane. + * + *

Four properties hold for every operation: a separate authority, an idempotent operation id, a + * recorded reason, and an audit row. The idempotency matters more than it looks — an operator + * retrying a redrive after a timeout must not send the message twice, which is exactly the failure + * the operation is trying to repair. + * + *

A dry run reads and reports but writes nothing, so an operator can see the blast radius of a + * bulk action before committing to it. + */ +public final class NotificationAdminServiceImpl implements NotificationAdminService { + + private final AdminAuthorizationGuard authorization; + private final DuplicateRiskGuard duplicateRiskGuard; + private final DeliveryAttemptStorePort attempts; + private final RecipientDeliveryStorePort recipients; + private final ReconciliationService reconciliation; + private final SuppressionStorePort suppressions; + private final ProviderRuntimeRegistry runtimes; + private final AdminOperationStorePort operations; + private final NotificationAuditPort audit; + private final TransactionPort transactions; + private final Clock clock; + + public NotificationAdminServiceImpl( + AdminAuthorizationGuard authorization, + DuplicateRiskGuard duplicateRiskGuard, + DeliveryAttemptStorePort attempts, + RecipientDeliveryStorePort recipients, + ReconciliationService reconciliation, + SuppressionStorePort suppressions, + ProviderRuntimeRegistry runtimes, + AdminOperationStorePort operations, + NotificationAuditPort audit, + TransactionPort transactions, + Clock clock) { + this.authorization = Objects.requireNonNull(authorization, "authorization"); + this.duplicateRiskGuard = Objects.requireNonNull(duplicateRiskGuard, "duplicateRiskGuard"); + this.attempts = Objects.requireNonNull(attempts, "attempts"); + this.recipients = Objects.requireNonNull(recipients, "recipients"); + this.reconciliation = Objects.requireNonNull(reconciliation, "reconciliation"); + this.suppressions = Objects.requireNonNull(suppressions, "suppressions"); + this.runtimes = Objects.requireNonNull(runtimes, "runtimes"); + this.operations = Objects.requireNonNull(operations, "operations"); + this.audit = Objects.requireNonNull(audit, "audit"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @Override + public AdminOperationResult redrive(RedriveCommand command, AdminActor actor) { + Objects.requireNonNull(command, "command"); + authorization.require(actor, NotificationAdminAuthority.REDRIVE); + + Optional replayed = operations.findByOperationId(command.operationId()); + if (replayed.isPresent()) { + return replayed.get(); + } + + DeliveryAttemptSnapshot original = + attempts + .snapshot(command.attemptId()) + .orElseThrow(() -> new IllegalStateException("delivery attempt is not available")); + authorization.requireTenant(actor, original.tenantId()); + duplicateRiskGuard.verify(original, command.approveDuplicateRisk()); + + if (command.dryRun()) { + return new AdminOperationResult( + command.operationId(), + true, + 1, + Optional.of(original.notificationId()), + Optional.of(original.recipientDeliveryId()), + Optional.empty(), + List.of("DRY_RUN")); + } + + return transactions.inWrite( + () -> { + // The logical identities are preserved and only the attempt is new, so the history stays + // one story rather than becoming two unrelated notifications. + recipients.transition( + original.recipientDeliveryId(), + RecipientDeliveryState.READY_TO_DISPATCH, + Optional.of(clock.instant())); + + AdminOperationResult result = + new AdminOperationResult( + command.operationId(), + false, + 1, + Optional.of(original.notificationId()), + Optional.of(original.recipientDeliveryId()), + Optional.empty(), + List.of(command.reason())); + audit.record( + new NotificationAuditEvent( + "ADMIN_REDRIVE", + actor.actorRef(), + Optional.of(command.reason()), + Optional.of(command.operationId()), + clock.instant(), + Map.of( + "provider", original.providerId().value(), + "channel", original.channel().name()))); + return operations.save(result, actor, "ADMIN_REDRIVE"); + }); + } + + @Override + public AdminOperationResult reconcile(ReconcileCommand command, AdminActor actor) { + Objects.requireNonNull(command, "command"); + authorization.require(actor, NotificationAdminAuthority.RECONCILE); + + Optional replayed = operations.findByOperationId(command.operationId()); + if (replayed.isPresent()) { + return replayed.get(); + } + if (command.dryRun()) { + return new AdminOperationResult( + command.operationId(), + true, + command.attemptIds().size(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of("DRY_RUN")); + } + + List reasons = new ArrayList<>(); + int reconciled = 0; + for (DeliveryAttemptId attemptId : command.attemptIds()) { + reconciliation.reconcile(attemptId); + reconciled++; + } + reasons.add(command.reason()); + + AdminOperationResult result = + new AdminOperationResult( + command.operationId(), + false, + reconciled, + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.copyOf(reasons)); + audit.record( + new NotificationAuditEvent( + "ADMIN_RECONCILE", + actor.actorRef(), + Optional.of(command.reason()), + Optional.of(command.operationId()), + clock.instant(), + Map.of())); + return operations.save(result, actor, "ADMIN_RECONCILE"); + } + + @Override + public AdminOperationResult suppress(SuppressCommand command, AdminActor actor) { + Objects.requireNonNull(command, "command"); + authorization.require(actor, NotificationAdminAuthority.SUPPRESS); + authorization.requireTenant(actor, command.tenantId()); + + Optional replayed = operations.findByOperationId(command.operationId()); + if (replayed.isPresent()) { + return replayed.get(); + } + if (command.dryRun()) { + return new AdminOperationResult( + command.operationId(), + true, + 1, + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of("DRY_RUN")); + } + + return transactions.inWrite( + () -> { + int affected; + if (command.remove()) { + // Removal is by fingerprint match rather than by id, because an operator lifting a + // suppression knows the target, not the row identifier the platform assigned. + affected = + suppressions + .activeFor( + command.tenantId(), command.targetFingerprint(), clock.instant()) + .stream() + .map(entry -> suppressions.remove(command.tenantId(), entry.id())) + .filter(Optional::isPresent) + .count() + > 0 + ? 1 + : 0; + } else { + suppressions.upsert( + new SuppressionEntry( + new SuppressionId(UUID.randomUUID()), + command.tenantId(), + command.scope(), + command.reason(), + command.targetFingerprint(), + Optional.empty(), + clock.instant(), + command.expiresAt(), + SuppressionSource.ADMIN)); + affected = 1; + } + + AdminOperationResult result = + new AdminOperationResult( + command.operationId(), + false, + affected, + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of(command.reasonText())); + audit.record( + new NotificationAuditEvent( + command.remove() ? "ADMIN_SUPPRESSION_REMOVED" : "ADMIN_SUPPRESSION_ADDED", + actor.actorRef(), + Optional.of(command.reason().name()), + Optional.of(command.operationId()), + clock.instant(), + Map.of())); + return operations.save( + result, actor, command.remove() ? "ADMIN_SUPPRESS_REMOVE" : "ADMIN_SUPPRESS_ADD"); + }); + } + + @Override + public AdminOperationResult setProviderState(SetProviderStateCommand command, AdminActor actor) { + Objects.requireNonNull(command, "command"); + authorization.require(actor, NotificationAdminAuthority.PROVIDER_CONTROL); + + Optional replayed = operations.findByOperationId(command.operationId()); + if (replayed.isPresent()) { + return replayed.get(); + } + if (command.dryRun()) { + return new AdminOperationResult( + command.operationId(), + true, + 1, + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of("DRY_RUN")); + } + + var runtime = runtimes.current(command.profileId()); + switch (command.desiredState()) { + case DISABLED -> runtime.markDisabled(); + case DRAINING -> runtime.markDraining(); + case HEALTHY -> runtime.markHealthy(); + case DEGRADED -> runtime.markDegraded(command.reason()); + case THROTTLED -> runtime.markThrottled(); + case AUTHENTICATION_FAILED -> runtime.markAuthenticationFailed(command.reason()); + } + + AdminOperationResult result = + new AdminOperationResult( + command.operationId(), + false, + 1, + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of(command.reason())); + audit.record( + new NotificationAuditEvent( + "ADMIN_PROVIDER_STATE", + actor.actorRef(), + Optional.of(command.reason()), + Optional.of(command.operationId()), + clock.instant(), + Map.of( + "providerProfile", command.profileId().value(), + "status", command.desiredState().name()))); + return operations.save(result, actor, "ADMIN_PROVIDER_STATE"); + } + + /** Current state of a provider runtime, for the health endpoint. */ + public ProviderRuntimeState providerState( + dev.caskeleton.application.notification.platform.api.ProviderProfileId profileId) { + return runtimes.state(profileId); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformAutoConfiguration.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformAutoConfiguration.java new file mode 100644 index 00000000..4e1c2642 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformAutoConfiguration.java @@ -0,0 +1,105 @@ +package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector; +import dev.caskeleton.adapter.outbound.notification.platform.template.JacksonNotificationVariablesCodec; +import dev.caskeleton.adapter.outbound.notification.platform.template.JsonSchemaVariableValidator; +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationTemplateEngine; +import dev.caskeleton.adapter.outbound.notification.platform.template.PlaceholderTemplateEngine; +import dev.caskeleton.adapter.outbound.notification.platform.template.Sha256MessageDigestAdapter; +import dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafStringTemplateEngine; +import dev.caskeleton.application.notification.platform.dispatch.MessageDigestPort; +import dev.caskeleton.application.notification.platform.dispatch.NotificationVariablesCodecPort; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.template.TemplateVariableValidator; +import java.time.Duration; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Notification platform wiring. + * + *

Everything is opt-in and conditional. The platform contributes no beans unless it is enabled, + * and the contact point protector only appears once a secret provider exists — because a protector + * without keys would fail on the first delivery instead of at startup. + */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(NotificationPlatformSettings.class) +@ConditionalOnProperty( + prefix = "ca-skeleton.notification.platform", + name = "enabled", + havingValue = "true") +public class NotificationPlatformAutoConfiguration { + + /** Canonical variables codec. */ + @Bean + @ConditionalOnMissingBean + public NotificationVariablesCodecPort notificationVariablesCodec() { + return new JacksonNotificationVariablesCodec(); + } + + /** Request fingerprint hashing. */ + @Bean + @ConditionalOnMissingBean + public MessageDigestPort notificationMessageDigest() { + return new Sha256MessageDigestAdapter(); + } + + /** JSON Schema 2020-12 variable validation. */ + @Bean + @ConditionalOnMissingBean + public TemplateVariableValidator notificationTemplateVariableValidator() { + return new JsonSchemaVariableValidator(); + } + + /** + * Template engine, defaulting to the deterministic placeholder substitution. + * + *

Thymeleaf is the opt-in alternative: it escapes by default, which matters for HTML email + * bodies built from application input. The default stays the placeholder engine because it has no + * expression evaluator at all, and an unknown engine name fails the boot rather than quietly + * falling back — a deployment that thought it had escaping and did not is the worse outcome. + */ + @Bean + @ConditionalOnMissingBean + public NotificationTemplateEngine notificationTemplateEngine( + @Value("${ca-skeleton.notification.platform.template.engine:placeholder}") String engine) { + return switch (engine.toLowerCase(java.util.Locale.ROOT)) { + case "placeholder" -> new PlaceholderTemplateEngine(); + case "thymeleaf" -> new ThymeleafStringTemplateEngine(); + default -> + throw new IllegalArgumentException( + "ca-skeleton.notification.platform.template.engine must be" + + " 'placeholder' or 'thymeleaf', not '" + + engine + + "'"); + }; + } + + /** Contact point protection, only once key material is available. */ + @Bean + @ConditionalOnBean(SecretMaterialProvider.class) + @ConditionalOnMissingBean + public ContactPointProtector notificationContactPointProtector(SecretMaterialProvider secrets) { + return new AesGcmContactPointProtector(secrets); + } + + /** + * Default provider transport. + * + *

Replaced in the composition root when the HTTP Client Platform is bound, which is the + * supported way to reuse its TLS, circuit-breaker and SSRF policy. + */ + @Bean + @ConditionalOnMissingBean + public NotificationHttpGateway notificationHttpGateway() { + return new JdkNotificationHttpGateway(Duration.ofSeconds(2)); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformSettings.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformSettings.java new file mode 100644 index 00000000..51147faf --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformSettings.java @@ -0,0 +1,154 @@ +package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure; + +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Bound notification platform configuration. + * + *

Validation happens in the constructor, so a misconfiguration fails the boot rather than + * surfacing as a delivery incident hours later. Everything is bounded: there is no property whose + * value may be "unlimited", because an unbounded queue or payload is a resource failure waiting for + * the first burst. + */ +@ConfigurationProperties("ca-skeleton.notification.platform") +public record NotificationPlatformSettings( + boolean enabled, Dispatch dispatch, Callbacks callbacks, Map providers) { + + public NotificationPlatformSettings { + dispatch = dispatch == null ? Dispatch.defaults() : dispatch; + callbacks = callbacks == null ? Callbacks.defaults() : callbacks; + providers = providers == null ? Map.of() : Map.copyOf(providers); + providers.forEach((id, provider) -> provider.validate(id)); + } + + /** Dispatch runtime bounds. */ + public record Dispatch( + int claimBatchSize, + Duration leaseDuration, + Duration pollInterval, + int maxGlobalConcurrency, + int maxAdditionalAttempts, + Duration maxQueueAge, + boolean allowAmbiguousFallback) { + + private static final int MAX_CLAIM_BATCH = 1000; + + public Dispatch { + Objects.requireNonNull(leaseDuration, "leaseDuration"); + Objects.requireNonNull(pollInterval, "pollInterval"); + Objects.requireNonNull(maxQueueAge, "maxQueueAge"); + if (claimBatchSize < 1 || claimBatchSize > MAX_CLAIM_BATCH) { + throw new IllegalArgumentException( + "ca-skeleton.notification.platform.dispatch.claim-batch-size must be 1.." + + MAX_CLAIM_BATCH); + } + if (maxGlobalConcurrency < 1) { + throw new IllegalArgumentException("max-global-concurrency must be positive"); + } + if (maxAdditionalAttempts < 0) { + throw new IllegalArgumentException("max-additional-attempts must not be negative"); + } + if (leaseDuration.isNegative() || leaseDuration.isZero()) { + throw new IllegalArgumentException("lease-duration must be positive and finite"); + } + if (leaseDuration.compareTo(pollInterval) <= 0) { + throw new IllegalArgumentException("lease-duration must exceed poll-interval"); + } + if (allowAmbiguousFallback) { + // Refused outright rather than warned about: automatic fallback after an ambiguous + // submission is the configuration that turns an unknown into a guaranteed duplicate. + throw new IllegalArgumentException( + "allow-ambiguous-fallback is not a supported configuration"); + } + } + + /** Conservative defaults. */ + public static Dispatch defaults() { + return new Dispatch( + 100, Duration.ofSeconds(30), Duration.ofMillis(250), 128, 3, Duration.ofHours(24), false); + } + } + + /** Callback endpoint bounds. */ + public record Callbacks(boolean enabled, long maxBodyBytes, Duration replaySkew) { + + private static final long MAX_BODY_CEILING = 1_048_576L; + + public Callbacks { + Objects.requireNonNull(replaySkew, "replaySkew"); + if (maxBodyBytes < 1 || maxBodyBytes > MAX_BODY_CEILING) { + throw new IllegalArgumentException("max-body-bytes must be 1.." + MAX_BODY_CEILING); + } + if (replaySkew.isNegative()) { + throw new IllegalArgumentException("replay-skew must not be negative"); + } + } + + /** Conservative defaults. */ + public static Callbacks defaults() { + return new Callbacks(false, 65_536L, Duration.ofMinutes(5)); + } + } + + /** One provider profile. */ + public record Provider( + String type, + boolean enabled, + String environment, + String credentialProfile, + String topic, + String vapidPublicKey, + String callbackSigningSecretRef, + Duration timeout, + int maxConcurrency, + int ratePerSecond) { + + /** Fail the boot when a profile cannot possibly work. */ + public void validate(String profileId) { + Objects.requireNonNull(profileId, "profileId"); + if (!enabled) { + return; + } + require(type != null && !type.isBlank(), profileId, "type is required"); + require(environment != null && !environment.isBlank(), profileId, "environment is required"); + require( + credentialProfile != null && !credentialProfile.isBlank(), + profileId, + "credential-profile is required"); + require( + timeout != null && !timeout.isNegative() && !timeout.isZero(), + profileId, + "timeout must be positive and finite"); + require(maxConcurrency >= 1, profileId, "max-concurrency must be positive"); + require(ratePerSecond >= 1, profileId, "rate-limit-per-second must be positive"); + + switch (type == null ? "" : type.toUpperCase(java.util.Locale.ROOT)) { + case "APNS" -> + require(topic != null && !topic.isBlank(), profileId, "APNs profiles require a topic"); + case "WEB_PUSH" -> + require( + vapidPublicKey != null && !vapidPublicKey.isBlank(), + profileId, + "Web Push profiles require a VAPID key"); + case "TWILIO", "SES" -> + require( + callbackSigningSecretRef != null && !callbackSigningSecretRef.isBlank(), + profileId, + "callback-capable profiles require a callback signing secret reference"); + default -> { + // Providers without extra requirements are already covered by the common checks. + } + } + } + + private static void require(boolean condition, String profileId, String message) { + if (!condition) { + throw new IllegalArgumentException( + "notification provider profile '" + profileId + "': " + message); + } + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/AttemptPermit.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/AttemptPermit.java new file mode 100644 index 00000000..d9725875 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/AttemptPermit.java @@ -0,0 +1,17 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +/** + * A held concurrency slot for one provider attempt. + * + *

Closing it is what releases the slot, so every call site uses try-with-resources. The permit + * also carries the credential generation the attempt ran under, which is what makes a rotation + * auditable after the fact. + */ +public interface AttemptPermit extends AutoCloseable { + + /** Credential generation this attempt is bound to. */ + long generation(); + + @Override + void close(); +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CapabilityReconciliationGateway.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CapabilityReconciliationGateway.java new file mode 100644 index 00000000..e565164a --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CapabilityReconciliationGateway.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot; +import dev.caskeleton.application.notification.platform.dispatch.ReconciliationGatewayPort; +import dev.caskeleton.application.notification.platform.provider.ReconciliationCapability; +import dev.caskeleton.application.notification.platform.provider.ReconciliationResult; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Routes a reconciliation to the capability that owns the provider. + * + *

A profile with no registered capability reports {@code Unsupported} rather than falling back + * to a guess. Inventing a final status for a provider that cannot be queried is precisely the + * behaviour the ambiguity model exists to prevent. + */ +public final class CapabilityReconciliationGateway implements ReconciliationGatewayPort { + + private final Map capabilities; + private final ProviderRuntimeRegistry runtimes; + + public CapabilityReconciliationGateway( + Map capabilities, + ProviderRuntimeRegistry runtimes) { + this.capabilities = Map.copyOf(Objects.requireNonNull(capabilities, "capabilities")); + this.runtimes = Objects.requireNonNull(runtimes, "runtimes"); + } + + @Override + public boolean supports(ProviderProfileId profileId) { + Objects.requireNonNull(profileId, "profileId"); + return Optional.ofNullable(capabilities.get(profileId)) + .map(capability -> capability.supports(runtimes.current(profileId).profile())) + .orElse(false); + } + + @Override + public ReconciliationResult reconcile(DeliveryAttemptSnapshot attempt) { + Objects.requireNonNull(attempt, "attempt"); + ReconciliationCapability capability = capabilities.get(attempt.providerProfileId()); + if (capability == null) { + return new ReconciliationResult.Unsupported(); + } + // The permit is taken so a reconciliation backlog cannot become a second load source during the + // incident that produced it. + try (AttemptPermit permit = runtimes.current(attempt.providerProfileId()).acquireAttempt()) { + return capability.reconcile(attempt).toCompletableFuture().join(); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ConfiguredRoutePlanner.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ConfiguredRoutePlanner.java new file mode 100644 index 00000000..3a2b8261 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ConfiguredRoutePlanner.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.api.RecipientSpec; +import dev.caskeleton.application.notification.platform.api.TenantId; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.api.routing.DeliveryStrategy; +import dev.caskeleton.application.notification.platform.api.routing.ExplicitChannel; +import dev.caskeleton.application.notification.platform.api.routing.OrderedFallback; +import dev.caskeleton.application.notification.platform.dispatch.NotificationRoutePlannerPort; +import dev.caskeleton.application.notification.platform.policy.RouteCandidate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Turns a strategy into an ordered route plan using the configured channel-to-profile map. + * + *

A channel with no configured provider, or a recipient with no contact point for it, simply + * produces no candidate. The routing engine then reports {@code NO_ELIGIBLE_ROUTE} rather than the + * dispatcher failing on a null, which is the difference between a diagnosable state and a stack + * trace. + */ +public final class ConfiguredRoutePlanner implements NotificationRoutePlannerPort { + + private final Map profilesByChannel; + + public ConfiguredRoutePlanner(Map profilesByChannel) { + this.profilesByChannel = + Map.copyOf(Objects.requireNonNull(profilesByChannel, "profilesByChannel")); + } + + @Override + public List plan( + TenantId tenantId, RecipientSpec recipient, DeliveryStrategy strategy) { + Objects.requireNonNull(tenantId, "tenantId"); + Objects.requireNonNull(recipient, "recipient"); + Objects.requireNonNull(strategy, "strategy"); + + List ordered = + switch (strategy) { + case ExplicitChannel explicit -> List.of(explicit.channel()); + case OrderedFallback fallback -> fallback.channels(); + }; + + List routes = new ArrayList<>(ordered.size()); + int index = 0; + for (Channel channel : ordered) { + ProviderProfileId profileId = profilesByChannel.get(channel); + if (profileId == null) { + continue; + } + var selector = + recipient.contactPoints().stream() + .filter(candidate -> candidate.channel() == channel) + .findFirst(); + if (selector.isEmpty()) { + continue; + } + boolean blocked = + recipient + .channelOverride() + .map(override -> override.blockedChannels().contains(channel)) + .orElse(false); + routes.add( + new RouteCandidate( + index++, channel, selector.get().contactPointId(), profileId, !blocked, true)); + } + return List.copyOf(routes); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CredentialProbe.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CredentialProbe.java new file mode 100644 index 00000000..b41729af --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CredentialProbe.java @@ -0,0 +1,9 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +/** Verifies a candidate generation before it becomes the current one. */ +@FunctionalInterface +public interface CredentialProbe { + + /** Return false when the candidate credential is not usable. */ + boolean isUsable(ProviderRuntime candidate); +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CredentialValidationException.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CredentialValidationException.java new file mode 100644 index 00000000..20c658fd --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CredentialValidationException.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationException; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; + +/** Raised when a candidate credential generation fails its probe before any cutover. */ +public class CredentialValidationException extends NotificationException { + + private static final long serialVersionUID = 1L; + + public CredentialValidationException() { + super( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID, + FailureCategory.AUTHENTICATION)); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/JacksonRoutingPlanCodec.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/JacksonRoutingPlanCodec.java new file mode 100644 index 00000000..6693cd0c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/JacksonRoutingPlanCodec.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.api.ContactPointId; +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.dispatch.NotificationRoutingPlanCodecPort; +import dev.caskeleton.application.notification.platform.policy.RouteCandidate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import tools.jackson.core.type.TypeReference; + +/** + * Route plan encoding. + * + *

The plan is frozen at submit time, so this is a snapshot format rather than a view: it stores + * exactly what was decided, including which routes were usable then, and never recomputes. + */ +public final class JacksonRoutingPlanCodec implements NotificationRoutingPlanCodecPort { + + @Override + public String encode(List routes) { + Objects.requireNonNull(routes, "routes"); + List> encoded = new ArrayList<>(routes.size()); + for (RouteCandidate route : routes) { + Map entry = new LinkedHashMap<>(); + entry.put("routeIndex", route.routeIndex()); + entry.put("channel", route.channel().name()); + entry.put("contactPointId", route.contactPointId().value().toString()); + entry.put("providerProfileId", route.providerProfileId().value()); + entry.put("contactPointActive", route.contactPointActive()); + entry.put("providerEnabled", route.providerEnabled()); + encoded.add(entry); + } + return NotificationJsonMapper.mapper().writeValueAsString(encoded); + } + + @Override + public List decode(String payload) { + Objects.requireNonNull(payload, "payload"); + List> raw = + NotificationJsonMapper.mapper() + .readValue(payload, new TypeReference>>() {}); + List routes = new ArrayList<>(raw.size()); + for (Map entry : raw) { + routes.add( + new RouteCandidate( + ((Number) entry.get("routeIndex")).intValue(), + Channel.valueOf(String.valueOf(entry.get("channel"))), + new ContactPointId(UUID.fromString(String.valueOf(entry.get("contactPointId")))), + new ProviderProfileId(String.valueOf(entry.get("providerProfileId"))), + Boolean.TRUE.equals(entry.get("contactPointActive")), + Boolean.TRUE.equals(entry.get("providerEnabled")))); + } + return List.copyOf(routes); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/LeaseRecoveryService.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/LeaseRecoveryService.java new file mode 100644 index 00000000..171269ff --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/LeaseRecoveryService.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId; +import dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptStorePort; +import dev.caskeleton.application.notification.platform.dispatch.RecipientLeaseStorePort; +import dev.caskeleton.application.notification.platform.dispatch.ReconciliationService; +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +/** + * Recovers deliveries a dead worker left in flight. + * + *

An expired lease on a {@code DISPATCHING} delivery is the crash case: the attempt row exists, + * so a provider call may have happened. Recovery therefore reconciles rather than re-dispatching — + * re-dispatching would be the platform choosing to duplicate rather than to ask. + */ +public final class LeaseRecoveryService { + + private final RecipientLeaseStorePort leases; + private final DeliveryAttemptStorePort attempts; + private final ReconciliationService reconciliation; + private final Duration staleAfter; + private final int batchSize; + + public LeaseRecoveryService( + RecipientLeaseStorePort leases, + DeliveryAttemptStorePort attempts, + ReconciliationService reconciliation, + Duration staleAfter, + int batchSize) { + this.leases = Objects.requireNonNull(leases, "leases"); + this.attempts = Objects.requireNonNull(attempts, "attempts"); + this.reconciliation = Objects.requireNonNull(reconciliation, "reconciliation"); + this.staleAfter = Objects.requireNonNull(staleAfter, "staleAfter"); + this.batchSize = batchSize; + if (batchSize < 1) { + throw new IllegalArgumentException("batchSize"); + } + if (staleAfter.isNegative() || staleAfter.isZero()) { + throw new IllegalArgumentException("staleAfter must be positive and finite"); + } + } + + /** Recover one batch of abandoned deliveries; returns how many were handled. */ + public int recoverOnce() { + List abandoned = + leases.expiredDispatching(batchSize, staleAfter); + int handled = 0; + for (var recipientDeliveryId : abandoned) { + for (var attempt : attempts.attemptsOf(recipientDeliveryId)) { + if (attempt.completedAt().isEmpty()) { + DeliveryAttemptId attemptId = attempt.id(); + reconciliation.reconcile(attemptId); + handled++; + } + } + } + return handled; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/LoggingInboxSignalPublisher.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/LoggingInboxSignalPublisher.java new file mode 100644 index 00000000..24e24f3c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/LoggingInboxSignalPublisher.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.inbox.InboxItemCreated; +import dev.caskeleton.application.notification.platform.inbox.NotificationInboxSignalPort; +import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default inbox signal sink. + * + *

Emits identifiers only, never content. A deployment with a WebSocket or messaging relay + * replaces it; until then the inbox is still complete, because the row — not the signal — is the + * source of truth. + */ +public final class LoggingInboxSignalPublisher implements NotificationInboxSignalPort { + + private static final Logger log = LoggerFactory.getLogger("notification.inbox.signal"); + + @Override + public void publish(InboxItemCreated event) { + Objects.requireNonNull(event, "event"); + log.info( + "event=inbox_item_created itemId={} tenant={} category={}", + event.itemId().value(), + event.principal().tenantId().value(), + event.category()); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/MapTemplateRendererRegistry.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/MapTemplateRendererRegistry.java new file mode 100644 index 00000000..b2e3d8db --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/MapTemplateRendererRegistry.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.dispatch.TemplateRendererRegistry; +import dev.caskeleton.application.notification.platform.template.NotificationTemplateRenderer; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** Channel-to-renderer lookup built at wiring time. */ +public final class MapTemplateRendererRegistry implements TemplateRendererRegistry { + + private final Map renderers; + + public MapTemplateRendererRegistry(List renderers) { + Objects.requireNonNull(renderers, "renderers"); + Map byChannel = new EnumMap<>(Channel.class); + renderers.forEach(renderer -> byChannel.put(renderer.channel(), renderer)); + this.renderers = Map.copyOf(byChannel); + } + + @Override + public NotificationTemplateRenderer rendererFor(Channel channel) { + NotificationTemplateRenderer renderer = renderers.get(channel); + if (renderer == null) { + throw new IllegalStateException("no renderer registered for the channel"); + } + return renderer; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/NotificationDispatchProperties.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/NotificationDispatchProperties.java new file mode 100644 index 00000000..63ff2c75 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/NotificationDispatchProperties.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import java.time.Duration; +import java.util.Objects; + +/** + * Dispatch runtime bounds. + * + *

Every field is bounded and validated at construction. "Unlimited" is never an accepted value: + * an unbounded claim batch or queue is how a burst becomes an out-of-memory failure instead of + * backpressure. + */ +public record NotificationDispatchProperties( + int claimBatchSize, + Duration leaseDuration, + Duration pollInterval, + int maxGlobalConcurrency, + int maxAdditionalAttempts, + Duration estimatedDispatchDuration, + Duration maxQueueAge, + Duration shutdownGrace) { + + private static final int MAX_CLAIM_BATCH = 1000; + + public NotificationDispatchProperties { + Objects.requireNonNull(leaseDuration, "leaseDuration"); + Objects.requireNonNull(pollInterval, "pollInterval"); + Objects.requireNonNull(estimatedDispatchDuration, "estimatedDispatchDuration"); + Objects.requireNonNull(maxQueueAge, "maxQueueAge"); + Objects.requireNonNull(shutdownGrace, "shutdownGrace"); + if (claimBatchSize < 1 || claimBatchSize > MAX_CLAIM_BATCH) { + throw new IllegalArgumentException("claimBatchSize must be 1.." + MAX_CLAIM_BATCH); + } + if (maxGlobalConcurrency < 1) { + throw new IllegalArgumentException("maxGlobalConcurrency"); + } + if (maxAdditionalAttempts < 0) { + throw new IllegalArgumentException("maxAdditionalAttempts"); + } + requirePositive(leaseDuration, "leaseDuration"); + requirePositive(pollInterval, "pollInterval"); + requirePositive(maxQueueAge, "maxQueueAge"); + if (leaseDuration.compareTo(pollInterval) <= 0) { + // A lease shorter than the poll interval expires before the worker can renew it, so two + // workers would routinely claim the same job. + throw new IllegalArgumentException("leaseDuration must exceed pollInterval"); + } + } + + private static void requirePositive(Duration value, String name) { + if (value.isNegative() || value.isZero()) { + throw new IllegalArgumentException(name + " must be positive and finite"); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/NotificationSchedulerWorker.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/NotificationSchedulerWorker.java new file mode 100644 index 00000000..6212a8cb --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/NotificationSchedulerWorker.java @@ -0,0 +1,137 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.dispatch.NotificationDispatchService; +import dev.caskeleton.application.notification.platform.dispatch.RecipientLease; +import dev.caskeleton.application.notification.platform.dispatch.RecipientLeaseStorePort; +import dev.caskeleton.application.notification.platform.observation.NotificationMetricName; +import dev.caskeleton.application.notification.platform.observation.NotificationMetricsPort; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Claims due deliveries and hands them to the dispatcher. + * + *

The worker never calls a provider itself. It claims, submits to a bounded executor, and stops + * claiming the moment shutdown begins — so a rolling restart drains rather than abandoning leases + * that then have to time out. + * + *

Claiming is bounded twice over: by the claim batch size and by a global concurrency permit. + * The second bound matters because a slow provider would otherwise let the queue depth become the + * thread count. + */ +public final class NotificationSchedulerWorker implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(NotificationSchedulerWorker.class); + + private final RecipientLeaseStorePort leases; + private final NotificationDispatchService dispatcher; + private final NotificationMetricsPort metrics; + private final NotificationDispatchProperties properties; + private final String workerId; + private final ExecutorService dispatchExecutor; + private final Semaphore globalConcurrency; + private final AtomicBoolean running = new AtomicBoolean(); + private final AtomicBoolean shuttingDown = new AtomicBoolean(); + + public NotificationSchedulerWorker( + RecipientLeaseStorePort leases, + NotificationDispatchService dispatcher, + NotificationMetricsPort metrics, + NotificationDispatchProperties properties, + String workerId) { + this.leases = Objects.requireNonNull(leases, "leases"); + this.dispatcher = Objects.requireNonNull(dispatcher, "dispatcher"); + this.metrics = Objects.requireNonNull(metrics, "metrics"); + this.properties = Objects.requireNonNull(properties, "properties"); + this.workerId = Objects.requireNonNull(workerId, "workerId"); + this.dispatchExecutor = Executors.newVirtualThreadPerTaskExecutor(); + this.globalConcurrency = new Semaphore(properties.maxGlobalConcurrency()); + } + + /** Claim and dispatch one batch. Returns how many deliveries were claimed. */ + public int runOnce() { + if (shuttingDown.get()) { + return 0; + } + List claimed = + leases.claim(workerId, properties.claimBatchSize(), properties.leaseDuration()); + metrics.gauge(NotificationMetricName.QUEUE_DEPTH, Map.of(), claimed.size()); + + for (RecipientLease lease : claimed) { + globalConcurrency.acquireUninterruptibly(); + dispatchExecutor.execute( + () -> { + try { + dispatcher.dispatch(lease); + } catch (RuntimeException failure) { + // The lease is left to expire rather than being released optimistically: a worker + // that + // failed mid-dispatch cannot prove what the provider did. + log.warn( + "notification dispatch failed worker={} reason={}", + workerId, + failure.getClass().getSimpleName()); + } finally { + globalConcurrency.release(); + } + }); + } + return claimed.size(); + } + + /** Start the polling loop on a dedicated thread. */ + public void start() { + if (!running.compareAndSet(false, true)) { + return; + } + Thread.ofVirtual() + .name("notification-scheduler-" + workerId) + .start( + () -> { + while (running.get() && !shuttingDown.get()) { + try { + if (runOnce() == 0) { + Thread.sleep(properties.pollInterval().toMillis()); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } catch (RuntimeException failure) { + log.warn( + "notification scheduler tick failed worker={} reason={}", + workerId, + failure.getClass().getSimpleName()); + } + } + }); + } + + @Override + public void close() { + shuttingDown.set(true); + running.set(false); + dispatchExecutor.shutdown(); + try { + if (!dispatchExecutor.awaitTermination( + properties.shutdownGrace().toMillis(), TimeUnit.MILLISECONDS)) { + dispatchExecutor.shutdownNow(); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + dispatchExecutor.shutdownNow(); + } + } + + /** Whether the worker has stopped claiming new work. */ + public boolean shuttingDown() { + return shuttingDown.get(); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderAttemptLimiter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderAttemptLimiter.java new file mode 100644 index 00000000..8ab2e030 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderAttemptLimiter.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.ProviderUnavailableException; +import java.time.Clock; +import java.util.Objects; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Per-provider rate and concurrency guard. + * + *

Tokens are spent on real attempts only. A delivery waiting out its backoff holds no permit, + * because a provider outage would otherwise pin the whole concurrency budget on deliveries that are + * not doing anything. + */ +public final class ProviderAttemptLimiter { + + private final Semaphore concurrency; + private final int maxConcurrency; + private final int ratePerSecond; + private final Clock clock; + private final AtomicLong windowStartSecond = new AtomicLong(); + private final AtomicLong issuedInWindow = new AtomicLong(); + + public ProviderAttemptLimiter(int maxConcurrency, int ratePerSecond, Clock clock) { + if (maxConcurrency < 1) { + throw new IllegalArgumentException("maxConcurrency"); + } + if (ratePerSecond < 1) { + throw new IllegalArgumentException("ratePerSecond"); + } + this.concurrency = new Semaphore(maxConcurrency); + this.maxConcurrency = maxConcurrency; + this.ratePerSecond = ratePerSecond; + this.clock = Objects.requireNonNull(clock, "clock"); + this.windowStartSecond.set(clock.instant().getEpochSecond()); + } + + /** Acquire one attempt slot, or fail fast when the provider budget is spent. */ + public void acquire() { + long second = clock.instant().getEpochSecond(); + long windowStart = windowStartSecond.get(); + if (second != windowStart && windowStartSecond.compareAndSet(windowStart, second)) { + issuedInWindow.set(0L); + } + if (issuedInWindow.incrementAndGet() > ratePerSecond) { + throw unavailable(); + } + if (!concurrency.tryAcquire()) { + issuedInWindow.decrementAndGet(); + throw unavailable(); + } + } + + /** Release a previously acquired slot. */ + public void release() { + concurrency.release(); + } + + /** Slots currently held. */ + public int activeAttempts() { + return maxConcurrency - concurrency.availablePermits(); + } + + private static ProviderUnavailableException unavailable() { + return new ProviderUnavailableException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.PROVIDER_UNAVAILABLE, FailureCategory.CAPACITY_REJECTED)); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntime.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntime.java new file mode 100644 index 00000000..4703ecff --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntime.java @@ -0,0 +1,136 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.ProviderUnavailableException; +import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; +import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot; +import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +/** + * One immutable credential generation of a provider. + * + *

Generations are replaced, never mutated. Rotating a key by editing a live client would leave + * in-flight calls half-way between two credentials; replacing the whole runtime and letting the old + * one drain keeps every attempt attributable to exactly one generation. + * + *

An authentication failure moves the whole runtime, not the message. One expired credential + * multiplied by a queue of notifications is a self-inflicted outage, so the route opens once and + * raises an operational alert instead. + */ +public final class ProviderRuntime { + + private final ProviderProfileSnapshot profile; + private final NotificationProviderAdapter adapter; + private final ProviderAttemptLimiter limiter; + private final AtomicReference state; + private final AtomicReference unhealthyReason = new AtomicReference<>(); + + public ProviderRuntime( + ProviderProfileSnapshot profile, + NotificationProviderAdapter adapter, + ProviderAttemptLimiter limiter) { + this.profile = Objects.requireNonNull(profile, "profile"); + this.adapter = Objects.requireNonNull(adapter, "adapter"); + this.limiter = Objects.requireNonNull(limiter, "limiter"); + this.state = new AtomicReference<>(ProviderRuntimeState.HEALTHY); + } + + /** Profile snapshot including the credential generation. */ + public ProviderProfileSnapshot profile() { + return profile; + } + + /** Credential generation of this runtime. */ + public long generation() { + return profile.credentialGeneration(); + } + + /** Provider adapter bound to this generation. */ + public NotificationProviderAdapter adapter() { + return adapter; + } + + /** Current health. */ + public ProviderRuntimeState state() { + return state.get(); + } + + /** Why the runtime is unhealthy, if it is. */ + public Optional unhealthyReason() { + return Optional.ofNullable(unhealthyReason.get()); + } + + /** Attempts currently in flight on this generation. */ + public int activeAttempts() { + return limiter.activeAttempts(); + } + + /** + * Acquire a permit for one attempt. + * + *

The health check happens before the limiter, so a disabled or failed provider never consumes + * a token it cannot use. + */ + public AttemptPermit acquireAttempt() { + ProviderRuntimeState current = state.get(); + if (!current.admitsNewAttempts()) { + throw new ProviderUnavailableException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.PROVIDER_UNAVAILABLE, + current == ProviderRuntimeState.AUTHENTICATION_FAILED + ? FailureCategory.AUTHENTICATION + : FailureCategory.CAPACITY_REJECTED)); + } + limiter.acquire(); + return new LimiterPermit(profile.credentialGeneration(), limiter); + } + + /** Mark the credential as rejected by the provider. */ + public void markAuthenticationFailed(String reasonCode) { + unhealthyReason.set(Objects.requireNonNull(reasonCode, "reasonCode")); + state.set(ProviderRuntimeState.AUTHENTICATION_FAILED); + } + + /** Mark the provider as rate limited. */ + public void markThrottled() { + state.compareAndSet(ProviderRuntimeState.HEALTHY, ProviderRuntimeState.THROTTLED); + } + + /** Mark the provider as degraded but still usable. */ + public void markDegraded(String reasonCode) { + unhealthyReason.set(reasonCode); + state.compareAndSet(ProviderRuntimeState.HEALTHY, ProviderRuntimeState.DEGRADED); + } + + /** Return to healthy after a successful attempt. */ + public void markHealthy() { + unhealthyReason.set(null); + state.compareAndSet(ProviderRuntimeState.THROTTLED, ProviderRuntimeState.HEALTHY); + state.compareAndSet(ProviderRuntimeState.DEGRADED, ProviderRuntimeState.HEALTHY); + } + + /** Stop admitting new attempts; in-flight attempts finish. */ + public void markDraining() { + state.set(ProviderRuntimeState.DRAINING); + } + + /** Operator disable. */ + public void markDisabled() { + state.set(ProviderRuntimeState.DISABLED); + } + + /** A permit that releases exactly one limiter slot. */ + private record LimiterPermit(long generation, ProviderAttemptLimiter limiter) + implements AttemptPermit { + + @Override + public void close() { + limiter.release(); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRegistry.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRegistry.java new file mode 100644 index 00000000..a5d0145e --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRegistry.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** Holds the current generation of every provider profile plus the generations still draining. */ +public final class ProviderRuntimeRegistry { + + private final Map current = new ConcurrentHashMap<>(); + private final Map> draining = + new ConcurrentHashMap<>(); + + /** Register the first generation of a profile. */ + public void register(ProviderRuntime runtime) { + Objects.requireNonNull(runtime, "runtime"); + current.put(runtime.profile().profileId(), runtime); + } + + /** Current generation, or a configuration failure when the profile is unknown. */ + public ProviderRuntime current(ProviderProfileId profileId) { + ProviderRuntime runtime = current.get(profileId); + if (runtime == null) { + throw new IllegalStateException("no provider runtime registered for the profile"); + } + return runtime; + } + + /** Current generation if registered. */ + public Optional find(ProviderProfileId profileId) { + return Optional.ofNullable(current.get(profileId)); + } + + /** + * Swap in a new generation and start draining the old one. + * + *

New dispatches immediately use the new generation while the previous one finishes what it + * already started, which is what makes a credential rotation invisible to callers. + */ + public Optional replace(ProviderRuntime replacement) { + Objects.requireNonNull(replacement, "replacement"); + ProviderProfileId profileId = replacement.profile().profileId(); + ProviderRuntime previous = current.put(profileId, replacement); + if (previous != null) { + previous.markDraining(); + draining.computeIfAbsent(profileId, key -> new CopyOnWriteArrayList<>()).add(previous); + forgetIfDrained(profileId); + } + return Optional.ofNullable(previous); + } + + /** Generations that are draining and still have work in flight. */ + public List drainingGenerations(ProviderProfileId profileId) { + forgetIfDrained(profileId); + return List.copyOf(draining.getOrDefault(profileId, new CopyOnWriteArrayList<>())); + } + + /** Health of the current generation. */ + public ProviderRuntimeState state(ProviderProfileId profileId) { + return current(profileId).state(); + } + + private void forgetIfDrained(ProviderProfileId profileId) { + CopyOnWriteArrayList generations = draining.get(profileId); + if (generations == null) { + return; + } + generations.removeIf(runtime -> runtime.activeAttempts() == 0); + if (generations.isEmpty()) { + draining.remove(profileId); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRotator.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRotator.java new file mode 100644 index 00000000..c9cd5ec3 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRotator.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.observation.NotificationAuditEvent; +import dev.caskeleton.application.notification.platform.observation.NotificationAuditPort; +import java.time.Clock; +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Credential and certificate rotation. + * + *

The candidate is probed before the swap. Validating after cutover would mean a typo + * in a rotated secret takes the provider down and only then tells anyone; validating first makes a + * bad candidate a no-op that leaves the working generation in place. + * + *

Only the generation and key id reach the audit trail — never the credential material itself. + */ +public final class ProviderRuntimeRotator { + + private final ProviderRuntimeRegistry registry; + private final CredentialProbe probe; + private final RuntimeDrainCoordinator drainCoordinator; + private final NotificationAuditPort audit; + private final Clock clock; + private final Duration drainTimeout; + + public ProviderRuntimeRotator( + ProviderRuntimeRegistry registry, + CredentialProbe probe, + RuntimeDrainCoordinator drainCoordinator, + NotificationAuditPort audit, + Clock clock, + Duration drainTimeout) { + this.registry = Objects.requireNonNull(registry, "registry"); + this.probe = Objects.requireNonNull(probe, "probe"); + this.drainCoordinator = Objects.requireNonNull(drainCoordinator, "drainCoordinator"); + this.audit = Objects.requireNonNull(audit, "audit"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.drainTimeout = Objects.requireNonNull(drainTimeout, "drainTimeout"); + } + + /** Cut over to a new credential generation. */ + public void rotate(ProviderProfileId profileId, ProviderRuntime candidate) { + Objects.requireNonNull(profileId, "profileId"); + Objects.requireNonNull(candidate, "candidate"); + if (!candidate.profile().profileId().equals(profileId)) { + throw new IllegalArgumentException("candidate belongs to a different profile"); + } + if (!probe.isUsable(candidate)) { + throw new CredentialValidationException(); + } + + Optional previous = registry.replace(candidate); + audit.record( + new NotificationAuditEvent( + "PROVIDER_CREDENTIAL_ROTATION", + "system", + Optional.of("ROTATION"), + Optional.empty(), + clock.instant(), + Map.of( + "providerProfile", profileId.value(), + "generation", Long.toString(candidate.generation())))); + previous.ifPresent(runtime -> drainCoordinator.drain(runtime, drainTimeout)); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/RegistryProviderDispatchGateway.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/RegistryProviderDispatchGateway.java new file mode 100644 index 00000000..9249058d --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/RegistryProviderDispatchGateway.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.dispatch.ProviderDispatchGatewayPort; +import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot; +import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; +import java.util.Objects; +import java.util.concurrent.CompletionException; + +/** + * The single outbound call, wrapped in a permit. + * + *

The permit is acquired before the call and released in a finally, so a provider that hangs + * consumes exactly one slot and a burst queues rather than exhausting the pool. + * + *

A credential rejection is promoted to a runtime state change here rather than being left as a + * per-message failure — one expired key must open the route once, not produce one retry per queued + * notification. + */ +public final class RegistryProviderDispatchGateway implements ProviderDispatchGatewayPort { + + private final ProviderRuntimeRegistry runtimes; + + public RegistryProviderDispatchGateway(ProviderRuntimeRegistry runtimes) { + this.runtimes = Objects.requireNonNull(runtimes, "runtimes"); + } + + @Override + public ProviderProfileSnapshot profile(ProviderProfileId profileId) { + return runtimes.current(profileId).profile(); + } + + @Override + public ProviderRuntimeState state(ProviderProfileId profileId) { + return runtimes.state(profileId); + } + + @Override + public ProviderSubmissionResult submit(ProviderSubmission submission) { + Objects.requireNonNull(submission, "submission"); + ProviderRuntime runtime = runtimes.current(submission.profile().profileId()); + + try (AttemptPermit permit = runtime.acquireAttempt()) { + ProviderSubmissionResult result = + runtime.adapter().submit(submission).toCompletableFuture().join(); + applyHealth(runtime, result); + return result; + } catch (CompletionException failure) { + // Unwrapped so the dispatcher classifies the real cause rather than the future's wrapper. + Throwable cause = failure.getCause() == null ? failure : failure.getCause(); + throw cause instanceof RuntimeException runtimeFailure + ? runtimeFailure + : new IllegalStateException("provider submission failed", cause); + } + } + + private static void applyHealth(ProviderRuntime runtime, ProviderSubmissionResult result) { + result + .failure() + .ifPresentOrElse( + failure -> { + switch (failure.category()) { + case AUTHENTICATION, AUTHORIZATION -> + runtime.markAuthenticationFailed(failure.code()); + case THROTTLED -> runtime.markThrottled(); + case TRANSIENT_PROVIDER -> runtime.markDegraded(failure.code()); + default -> { + // A message-level failure says nothing about the provider's health. + } + } + }, + runtime::markHealthy); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/RuntimeDrainCoordinator.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/RuntimeDrainCoordinator.java new file mode 100644 index 00000000..8db03d07 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/RuntimeDrainCoordinator.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import java.time.Duration; +import java.util.Objects; + +/** + * Waits for a replaced generation to finish its in-flight attempts. + * + *

The deadline comes from {@link System#nanoTime()}, not from the injectable clock. A drain + * timeout is a real elapsed-time budget: driving it from a test clock that never advances turns the + * loop into a hang, and driving it from a wall clock makes it sensitive to time adjustments. + * + *

Draining is bounded on purpose. A provider that never answers must not hold a credential + * rotation open forever, so after the timeout the generation is abandoned and its attempts follow + * the normal ambiguity and reconciliation path rather than being cancelled mid-flight. + */ +public final class RuntimeDrainCoordinator { + + private final Duration pollInterval; + + public RuntimeDrainCoordinator(Duration pollInterval) { + this.pollInterval = Objects.requireNonNull(pollInterval, "pollInterval"); + if (pollInterval.isNegative() || pollInterval.isZero()) { + throw new IllegalArgumentException("pollInterval"); + } + } + + /** Drain a generation, returning whether it finished within the timeout. */ + public boolean drain(ProviderRuntime runtime, Duration timeout) { + Objects.requireNonNull(runtime, "runtime"); + Objects.requireNonNull(timeout, "timeout"); + long deadlineNanos = System.nanoTime() + timeout.toNanos(); + while (runtime.activeAttempts() > 0) { + if (System.nanoTime() - deadlineNanos >= 0) { + return false; + } + try { + Thread.sleep(pollInterval.toMillis()); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return false; + } + } + return true; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/SingleTenantContext.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/SingleTenantContext.java new file mode 100644 index 00000000..4b0fae79 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/SingleTenantContext.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.api.TenantId; +import dev.caskeleton.application.notification.platform.dispatch.TenantContextPort; +import java.util.Objects; + +/** + * Tenant context for a single-tenant deployment. + * + *

A multi-tenant deployment replaces this with a request-scoped implementation. It exists so + * that a single-tenant application still goes through the tenant boundary rather than around it — + * the store queries take a tenant either way, and a deployment that later becomes multi-tenant does + * not have to find every unscoped query. + */ +public final class SingleTenantContext implements TenantContextPort { + + private final TenantId tenantId; + + public SingleTenantContext(String tenantId) { + this.tenantId = new TenantId(Objects.requireNonNull(tenantId, "tenantId")); + } + + @Override + public TenantId currentTenant() { + return tenantId; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/UuidV7Generator.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/UuidV7Generator.java new file mode 100644 index 00000000..938430f0 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/UuidV7Generator.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.dispatch.NotificationIdGeneratorPort; +import java.security.SecureRandom; +import java.time.Clock; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; + +/** + * RFC 9562 UUIDv7. + * + *

Time-ordered rather than random because these identifiers are primary keys: a random UUID + * scatters inserts across the whole index, and a notification table takes the highest insert rate + * in the platform. + * + *

The monotonic counter guards the case two identifiers are requested inside the same + * millisecond, so ordering holds even under a burst. + */ +public final class UuidV7Generator implements NotificationIdGeneratorPort { + + private static final long VERSION_7 = 0x7000L; + private static final long VARIANT_RFC = 0x8000000000000000L; + + private final Clock clock; + private final SecureRandom random; + private final AtomicLong lastMillis = new AtomicLong(); + private final AtomicLong sequence = new AtomicLong(); + + public UuidV7Generator(Clock clock) { + this(clock, new SecureRandom()); + } + + UuidV7Generator(Clock clock, SecureRandom random) { + this.clock = Objects.requireNonNull(clock, "clock"); + this.random = Objects.requireNonNull(random, "random"); + } + + @Override + public UUID nextId() { + long millis = clock.millis(); + long previous = lastMillis.getAndSet(millis); + long counter = millis == previous ? sequence.incrementAndGet() : sequence.updateAndGet(x -> 0L); + + long high = (millis & 0xFFFFFFFFFFFFL) << 16; + high |= VERSION_7; + high |= counter & 0x0FFFL; + + long low = random.nextLong(); + low &= 0x3FFFFFFFFFFFFFFFL; + low |= VARIANT_RFC; + return new UUID(high, low); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/LoggingNotificationAudit.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/LoggingNotificationAudit.java new file mode 100644 index 00000000..b1926aa5 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/LoggingNotificationAudit.java @@ -0,0 +1,60 @@ +package dev.caskeleton.adapter.outbound.notification.platform.observation; + +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.observation.NotificationAuditEvent; +import dev.caskeleton.application.notification.platform.observation.NotificationAuditPort; +import dev.caskeleton.application.notification.platform.observation.NotificationSecurityAuditPort; +import java.util.Objects; +import java.util.TreeMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Audit sink on a dedicated logger. + * + *

Separate from the metrics logger because audit has different retention: a metric may be + * sampled away, while "who lifted this suppression, and why" has to survive. + * + *

A rejected callback signature is a security event, not a provider event, so it is recorded + * here and never in the ledger — otherwise anyone who can reach the endpoint could fill a delivery + * history with noise. + */ +public final class LoggingNotificationAudit + implements NotificationAuditPort, NotificationSecurityAuditPort { + + private static final Logger audit = LoggerFactory.getLogger("notification.audit"); + private static final Logger security = LoggerFactory.getLogger("notification.security"); + + @Override + public void record(NotificationAuditEvent event) { + Objects.requireNonNull(event, "event"); + audit.info( + "action={} actor={} reason={} operationId={} occurredAt={} attributes={}", + event.action(), + event.actorRef(), + event.reasonCode().orElse("-"), + event.operationId().orElse("-"), + event.occurredAt(), + new TreeMap<>(event.boundedAttributes())); + } + + @Override + public void callbackSignatureRejected(ProviderProfileId profileId, String reasonCode) { + Objects.requireNonNull(profileId, "profileId"); + // The payload is deliberately absent: a forged callback must not get its content into the log + // just by being rejected. + security.warn( + "event=callback_signature_rejected providerProfile={} reason={}", + profileId.value(), + reasonCode); + } + + @Override + public void callbackRejectedByLimit(ProviderProfileId profileId, String reasonCode) { + Objects.requireNonNull(profileId, "profileId"); + security.warn( + "event=callback_rejected_by_limit providerProfile={} reason={}", + profileId.value(), + reasonCode); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/LoggingNotificationMetrics.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/LoggingNotificationMetrics.java new file mode 100644 index 00000000..2398c7f8 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/LoggingNotificationMetrics.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.outbound.notification.platform.observation; + +import dev.caskeleton.application.notification.platform.observation.CardinalityGuard; +import dev.caskeleton.application.notification.platform.observation.NotificationMetricsPort; +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Structured-log metrics sink. + * + *

Every tag map passes the cardinality guard before it is emitted, so a stray notification id + * fails here rather than after it has already multiplied a time series into millions of them. + * + *

A Micrometer-backed implementation belongs in the composition root, which owns the registry; + * this one keeps the platform usable — and its tag discipline enforced — without one. + */ +public final class LoggingNotificationMetrics implements NotificationMetricsPort { + + private static final Logger log = LoggerFactory.getLogger("notification.metrics"); + + private final CardinalityGuard guard; + + public LoggingNotificationMetrics(CardinalityGuard guard) { + this.guard = Objects.requireNonNull(guard, "guard"); + } + + @Override + public void increment(String metricName, Map tags) { + guard.validate(tags); + log.info("metric={} kind=counter tags={}", metricName, ordered(tags)); + } + + @Override + public void record(String metricName, Map tags, Duration value) { + guard.validate(tags); + log.info("metric={} kind=timer millis={} tags={}", metricName, value.toMillis(), ordered(tags)); + } + + @Override + public void gauge(String metricName, Map tags, double value) { + guard.validate(tags); + log.info("metric={} kind=gauge value={} tags={}", metricName, value, ordered(tags)); + } + + private static Map ordered(Map tags) { + return new TreeMap<>(tags); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationHealthReporter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationHealthReporter.java new file mode 100644 index 00000000..26d9777f --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationHealthReporter.java @@ -0,0 +1,57 @@ +package dev.caskeleton.adapter.outbound.notification.platform.observation; + +import dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderRuntimeRegistry; +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Builds the operational snapshot. + * + *

A provider whose credentials were rejected reports unhealthy even though the process is fine: + * that is exactly the condition an operator needs paged on, and it is invisible from process-level + * health. + */ +public final class NotificationHealthReporter { + + private final ProviderRuntimeRegistry runtimes; + private final List monitoredProfiles; + + public NotificationHealthReporter( + ProviderRuntimeRegistry runtimes, List monitoredProfiles) { + this.runtimes = Objects.requireNonNull(runtimes, "runtimes"); + this.monitoredProfiles = List.copyOf(Objects.requireNonNull(monitoredProfiles, "profiles")); + } + + /** Current snapshot. */ + public NotificationHealthSnapshot snapshot() { + List providers = new ArrayList<>(); + boolean healthy = true; + + for (ProviderProfileId profileId : monitoredProfiles) { + var runtime = runtimes.find(profileId); + if (runtime.isEmpty()) { + healthy = false; + providers.add( + new NotificationHealthSnapshot.ProviderHealth(profileId.value(), "UNREGISTERED", 0, 0)); + continue; + } + ProviderRuntimeState state = runtime.get().state(); + if (state == ProviderRuntimeState.AUTHENTICATION_FAILED + || state == ProviderRuntimeState.DISABLED) { + healthy = false; + } + providers.add( + new NotificationHealthSnapshot.ProviderHealth( + profileId.value(), + state.name(), + runtime.get().generation(), + runtime.get().activeAttempts())); + } + + return new NotificationHealthSnapshot(healthy, providers, Map.of()); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationHealthSnapshot.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationHealthSnapshot.java new file mode 100644 index 00000000..a41a5704 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationHealthSnapshot.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.notification.platform.observation; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Operational view of the platform. + * + *

Provider states, credential generations and queue age — nothing else. A health endpoint is one + * of the least protected surfaces an application exposes, so a sender address or a credential + * reference appearing here would be a leak with a wide audience. + */ +public record NotificationHealthSnapshot( + boolean healthy, List providers, Map queue) { + + public NotificationHealthSnapshot { + providers = List.copyOf(Objects.requireNonNull(providers, "providers")); + queue = Map.copyOf(Objects.requireNonNull(queue, "queue")); + } + + /** One provider runtime's state. */ + public record ProviderHealth( + String profileId, String state, long credentialGeneration, int activeAttempts) { + + public ProviderHealth { + Objects.requireNonNull(profileId, "profileId"); + Objects.requireNonNull(state, "state"); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ProviderResults.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ProviderResults.java new file mode 100644 index 00000000..3536d27b --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ProviderResults.java @@ -0,0 +1,100 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.provider.ProviderExecutionEvidence; +import dev.caskeleton.application.notification.platform.provider.ProviderFailure; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; +import java.time.Duration; +import java.util.Optional; + +/** + * Shared translation from a transport failure into an evidence-carrying result. + * + *

Every HTTP provider adapter routes its transport failures through here, so the rule that + * "committed body plus no response equals ambiguous" is written once rather than re-derived per + * provider. + */ +public final class ProviderResults { + + private ProviderResults() {} + + /** Classify a transport failure. */ + public static ProviderSubmissionResult fromTransport( + NotificationHttpTransportException failure, Duration elapsed) { + if (failure.requestBodyCommitted()) { + return ProviderSubmissionResult.ambiguous( + new ProviderFailure( + NotificationFailureCode.PROVIDER_RESPONSE_LOST, + FailureCategory.AMBIGUOUS_SUBMISSION, + false, + Optional.empty(), + Optional.of(failure.reasonCode())), + ProviderExecutionEvidence.responseLost(), + elapsed); + } + return ProviderSubmissionResult.notSubmitted( + new ProviderFailure( + NotificationFailureCode.PROVIDER_TRANSIENT_FAILURE, + FailureCategory.TRANSIENT_PROVIDER, + true, + Optional.empty(), + Optional.of(failure.reasonCode())), + elapsed); + } + + /** Classify an HTTP status that is not provider-specific. */ + public static ProviderFailure fromStatus(int statusCode, Optional retryAfter) { + if (statusCode == 429) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_THROTTLED, + FailureCategory.THROTTLED, + true, + retryAfter, + Optional.of(Integer.toString(statusCode))); + } + if (statusCode == 401) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_AUTHENTICATION_FAILED, + FailureCategory.AUTHENTICATION, + false, + Optional.empty(), + Optional.of("401")); + } + if (statusCode == 403) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_AUTHORIZATION_FAILED, + FailureCategory.AUTHORIZATION, + false, + Optional.empty(), + Optional.of("403")); + } + if (statusCode >= 500) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_TRANSIENT_FAILURE, + FailureCategory.TRANSIENT_PROVIDER, + true, + retryAfter, + Optional.of(Integer.toString(statusCode))); + } + return new ProviderFailure( + NotificationFailureCode.PROVIDER_PERMANENT_FAILURE, + FailureCategory.PERMANENT_PROVIDER, + false, + Optional.empty(), + Optional.of(Integer.toString(statusCode))); + } + + /** Parse a {@code Retry-After} header expressed in seconds. */ + public static Optional retryAfter(Optional headerValue) { + return headerValue.flatMap( + value -> { + try { + return Optional.of(Duration.ofSeconds(Long.parseLong(value.trim()))); + } catch (NumberFormatException notSeconds) { + return Optional.empty(); + } + }); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/UnconfiguredAttachmentResolver.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/UnconfiguredAttachmentResolver.java new file mode 100644 index 00000000..579bf96a --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/UnconfiguredAttachmentResolver.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider; + +import dev.caskeleton.application.notification.platform.api.content.AttachmentRef; +import dev.caskeleton.application.notification.platform.api.error.AttachmentUnavailableException; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.provider.AttachmentAccessContext; +import dev.caskeleton.application.notification.platform.provider.AttachmentResolver; +import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment; + +/** + * The resolver used when no attachment source is wired. + * + *

It refuses rather than returning an empty stream. Sending a mail whose attachment is silently + * missing is worse than not sending it: the recipient is told something is attached and it is not. + * + *

The composition root replaces this with a file-server or object-storage backed resolver; both + * leaves are visible there, and neither is reachable from this one. + */ +public final class UnconfiguredAttachmentResolver implements AttachmentResolver { + + @Override + public ResolvedAttachment resolve(AttachmentRef reference, AttachmentAccessContext context) { + throw new AttachmentUnavailableException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.ATTACHMENT_UNAVAILABLE, FailureCategory.INVALID_PAYLOAD)); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsFailureClassifier.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsFailureClassifier.java new file mode 100644 index 00000000..c23f207d --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsFailureClassifier.java @@ -0,0 +1,67 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.apns; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.provider.ProviderFailure; +import java.util.Optional; +import java.util.Set; + +/** Maps APNs reason strings onto the stable failure vocabulary. */ +public final class ApnsFailureClassifier { + + private static final Set INVALID_TOKEN_REASONS = + Set.of("BadDeviceToken", "Unregistered", "DeviceTokenNotForTopic"); + private static final Set CONFIGURATION_REASONS = + Set.of("BadTopic", "TopicDisallowed", "BadCertificateEnvironment", "InvalidPushType"); + + /** Classify a non-2xx APNs response. */ + public ProviderFailure classify(NotificationHttpResponse response) { + Optional reason = reason(response); + if (reason.filter(INVALID_TOKEN_REASONS::contains).isPresent()) { + return new ProviderFailure( + NotificationFailureCode.CONTACT_POINT_INVALID, + FailureCategory.INVALID_RECIPIENT, + false, + Optional.empty(), + reason); + } + if (reason.filter(CONFIGURATION_REASONS::contains).isPresent()) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID, + FailureCategory.AUTHORIZATION, + false, + Optional.empty(), + reason); + } + if (reason.filter("ExpiredProviderToken"::equals).isPresent()) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_AUTHENTICATION_FAILED, + FailureCategory.AUTHENTICATION, + false, + Optional.empty(), + reason); + } + if (reason.filter("TooManyRequests"::equals).isPresent()) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_THROTTLED, + FailureCategory.THROTTLED, + true, + Optional.empty(), + reason); + } + return ProviderResults.fromStatus(response.statusCode(), Optional.empty()); + } + + private static Optional reason(NotificationHttpResponse response) { + try { + var node = NotificationJsonMapper.mapper().readTree(response.bodyAsString()); + var reason = node.get("reason"); + return reason == null || reason.isNull() ? Optional.empty() : Optional.of(reason.asString()); + } catch (RuntimeException unparseable) { + return Optional.empty(); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsNotificationProviderAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsNotificationProviderAdapter.java new file mode 100644 index 00000000..b16d78e1 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsNotificationProviderAdapter.java @@ -0,0 +1,100 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.apns; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException; +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; +import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; +import dev.caskeleton.application.notification.platform.security.AccessContext; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import java.time.Duration; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.Supplier; + +/** + * APNs adapter. + * + *

A 2xx is acceptance. Apple documents that an accepted notification may be delivered, stored or + * discarded, and that ordering is not guaranteed, so this adapter never produces a delivery outcome + * and the platform never uses APNs as an ordered event transport. + */ +public final class ApnsNotificationProviderAdapter implements NotificationProviderAdapter { + + private static final ProviderId PROVIDER_ID = new ProviderId("apns"); + + private final NotificationHttpGateway gateway; + private final ApnsRequestMapper mapper; + private final ApnsFailureClassifier classifier; + private final ContactPointProtector protector; + private final Supplier authorizationSupplier; + + public ApnsNotificationProviderAdapter( + NotificationHttpGateway gateway, + ApnsRequestMapper mapper, + ApnsFailureClassifier classifier, + ContactPointProtector protector, + Supplier authorizationSupplier, + ApnsProviderProperties properties) { + // The profile is required at construction so a missing topic or environment fails at wiring + // time, but it is never exposed: a public accessor would leak an adapter type across the port. + Objects.requireNonNull(properties, "properties"); + this.gateway = Objects.requireNonNull(gateway, "gateway"); + this.mapper = Objects.requireNonNull(mapper, "mapper"); + this.classifier = Objects.requireNonNull(classifier, "classifier"); + this.protector = Objects.requireNonNull(protector, "protector"); + this.authorizationSupplier = + Objects.requireNonNull(authorizationSupplier, "authorizationSupplier"); + } + + @Override + public ProviderId providerId() { + return PROVIDER_ID; + } + + @Override + public Set channels() { + return Set.of(Channel.PUSH); + } + + @Override + public ProviderCapabilities capabilities() { + return new ProviderCapabilities( + false, false, false, false, false, false, false, true, 1, 4096L, Duration.ofDays(30)); + } + + @Override + public CompletionStage submit(ProviderSubmission submission) { + Objects.requireNonNull(submission, "submission"); + return CompletableFuture.completedFuture(send(submission)); + } + + private ProviderSubmissionResult send(ProviderSubmission submission) { + long startedNanos = System.nanoTime(); + var contactPoint = + protector.reveal( + submission.contactPoint(), + AccessContext.dispatch(submission.profile().profileId().value())); + var request = mapper.map(submission, contactPoint, authorizationSupplier.get()); + + try { + NotificationHttpResponse response = gateway.exchange(request); + Duration elapsed = Duration.ofNanos(System.nanoTime() - startedNanos); + if (response.isSuccessful()) { + return ProviderSubmissionResult.accepted( + response.header("apns-id").orElse(null), "Accepted", elapsed); + } + return ProviderSubmissionResult.rejected(classifier.classify(response), elapsed); + } catch (NotificationHttpTransportException transportFailure) { + return ProviderResults.fromTransport( + transportFailure, Duration.ofNanos(System.nanoTime() - startedNanos)); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsProviderProperties.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsProviderProperties.java new file mode 100644 index 00000000..3f357179 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsProviderProperties.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.apns; + +import dev.caskeleton.application.notification.platform.contact.ApnsEnvironment; +import java.net.URI; +import java.time.Duration; +import java.util.Objects; +import java.util.Set; + +/** + * APNs profile. + * + *

Environment and topic are required. A sandbox token sent to the production host is a silent + * non-delivery, so the pairing is checked before the call rather than diagnosed afterwards. + */ +public record ApnsProviderProperties( + URI endpoint, + String topic, + ApnsEnvironment environment, + Set allowedPushTypes, + Duration timeout) { + + public ApnsProviderProperties { + Objects.requireNonNull(endpoint, "endpoint"); + Objects.requireNonNull(topic, "topic"); + Objects.requireNonNull(environment, "environment"); + allowedPushTypes = Set.copyOf(Objects.requireNonNull(allowedPushTypes, "allowedPushTypes")); + Objects.requireNonNull(timeout, "timeout"); + if (topic.isBlank()) { + throw new IllegalArgumentException("topic"); + } + if (allowedPushTypes.isEmpty()) { + throw new IllegalArgumentException("allowedPushTypes must not be empty"); + } + if (timeout.isNegative() || timeout.isZero()) { + throw new IllegalArgumentException("timeout must be positive and finite"); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsRequestMapper.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsRequestMapper.java new file mode 100644 index 00000000..4a87e0cc --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsRequestMapper.java @@ -0,0 +1,96 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.apns; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest; +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.api.content.MobilePushContent; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.ProviderConfigurationException; +import dev.caskeleton.application.notification.platform.contact.ApnsDeviceToken; +import dev.caskeleton.application.notification.platform.contact.ContactPointValue; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** Builds the APNs HTTP/2 request headers and payload. */ +public final class ApnsRequestMapper { + + private static final String DEFAULT_PUSH_TYPE = "alert"; + + private final ApnsProviderProperties properties; + private final Clock clock; + + public ApnsRequestMapper(ApnsProviderProperties properties, Clock clock) { + this.properties = Objects.requireNonNull(properties, "properties"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + /** Map one submission, rejecting an environment or push-type mismatch first. */ + public NotificationHttpRequest map( + ProviderSubmission submission, ContactPointValue contactPoint, String authorization) { + Objects.requireNonNull(submission, "submission"); + Objects.requireNonNull(authorization, "authorization"); + if (!(contactPoint instanceof ApnsDeviceToken token)) { + throw new IllegalArgumentException("APNs requires an APNs device token"); + } + if (token.environment() != properties.environment()) { + throw configurationFailure(); + } + if (!(submission.content().content() instanceof MobilePushContent push)) { + throw new IllegalArgumentException("APNs requires mobile push content"); + } + String pushType = DEFAULT_PUSH_TYPE; + if (!properties.allowedPushTypes().contains(pushType)) { + throw configurationFailure(); + } + + Map aps = new LinkedHashMap<>(); + aps.put("alert", Map.of("title", push.title(), "body", push.body())); + push.presentation().sound().ifPresent(sound -> aps.put("sound", sound)); + push.presentation().badge().ifPresent(badge -> aps.put("badge", badge)); + + Map payload = new LinkedHashMap<>(); + payload.put("aps", aps); + payload.putAll(push.data()); + + Map headers = new LinkedHashMap<>(); + headers.put("authorization", authorization); + headers.put("apns-topic", properties.topic()); + headers.put("apns-push-type", pushType); + headers.put("apns-priority", "10"); + headers.put("apns-id", submission.attemptId().value().toString()); + submission + .expiresAt() + .ifPresent( + expiry -> headers.put("apns-expiration", Long.toString(expiry.getEpochSecond()))); + submission.collapse().ifPresent(spec -> headers.put("apns-collapse-id", spec.key())); + + byte[] body = + NotificationJsonMapper.mapper() + .writeValueAsString(payload) + .getBytes(StandardCharsets.UTF_8); + return new NotificationHttpRequest( + "POST", + URI.create(properties.endpoint() + "/3/device/" + token.value()), + JdkNotificationHttpGateway.headers(headers), + body, + properties.timeout()); + } + + /** Current time, exposed so expiry mapping stays testable. */ + public java.time.Instant now() { + return clock.instant(); + } + + private static ProviderConfigurationException configurationFailure() { + return new ProviderConfigurationException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID, FailureCategory.AUTHORIZATION)); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchCoordinator.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchCoordinator.java new file mode 100644 index 00000000..2b270870 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchCoordinator.java @@ -0,0 +1,89 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm; + +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException; +import dev.caskeleton.application.notification.platform.contact.ContactPointValue; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; +import dev.caskeleton.application.notification.platform.security.AccessContext; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +/** + * Batch submission that keeps per-recipient identity. + * + *

One transport call, many attempts. FCM returns a positional result per input, so a partial + * failure is decomposed back to the recipient that owns it; collapsing a batch into one shared + * outcome would mark four delivered recipients as failed because the fifth token was stale. + */ +public final class FcmBatchCoordinator { + + private final FcmGateway gateway; + private final FcmMessageMapper messageMapper; + private final FcmTargetMapper targetMapper; + private final FcmFailureClassifier classifier; + private final ContactPointProtector protector; + private final FcmProviderProperties properties; + + public FcmBatchCoordinator( + FcmGateway gateway, + FcmMessageMapper messageMapper, + FcmTargetMapper targetMapper, + FcmFailureClassifier classifier, + ContactPointProtector protector, + FcmProviderProperties properties) { + this.gateway = Objects.requireNonNull(gateway, "gateway"); + this.messageMapper = Objects.requireNonNull(messageMapper, "messageMapper"); + this.targetMapper = Objects.requireNonNull(targetMapper, "targetMapper"); + this.classifier = Objects.requireNonNull(classifier, "classifier"); + this.protector = Objects.requireNonNull(protector, "protector"); + this.properties = Objects.requireNonNull(properties, "properties"); + } + + /** Submit a batch and return one result per input, in input order. */ + public CompletionStage> submit( + List submissions) { + Objects.requireNonNull(submissions, "submissions"); + if (submissions.isEmpty()) { + return CompletableFuture.completedFuture(List.of()); + } + if (submissions.size() > properties.maxBatchSize()) { + throw new ProviderPayloadLimitException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT, FailureCategory.INVALID_PAYLOAD)); + } + + long startedNanos = System.nanoTime(); + List> messages = new ArrayList<>(submissions.size()); + for (ProviderSubmission submission : submissions) { + ContactPointValue value = + protector.reveal( + submission.contactPoint(), + AccessContext.dispatch(submission.profile().profileId().value())); + messages.add(messageMapper.map(submission, targetMapper.map(value))); + } + + FcmBatchResult batch = gateway.sendBatch(messages); + if (batch.items().size() != submissions.size()) { + throw new IllegalStateException("FCM returned a result count that does not match the input"); + } + + Duration elapsed = Duration.ofNanos(System.nanoTime() - startedNanos); + List results = new ArrayList<>(submissions.size()); + for (FcmBatchResult.Item item : batch.items()) { + results.add( + item.success() + ? ProviderSubmissionResult.accepted(item.messageId().orElse(null), "SUCCESS", elapsed) + : classifier.classify(item.errorCode().orElseThrow(), elapsed)); + } + return CompletableFuture.completedFuture(List.copyOf(results)); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchResult.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchResult.java new file mode 100644 index 00000000..2a8a4b1c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchResult.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** Positional result of one FCM multicast call. */ +public record FcmBatchResult(List items) { + + public FcmBatchResult { + items = List.copyOf(Objects.requireNonNull(items, "items")); + } + + /** One item result, aligned with the input index. */ + public record Item(boolean success, Optional messageId, Optional errorCode) { + + public Item { + Objects.requireNonNull(messageId, "messageId"); + Objects.requireNonNull(errorCode, "errorCode"); + if (success == errorCode.isPresent()) { + throw new IllegalArgumentException("an item is either a success or an error, never both"); + } + } + + /** Successful item. */ + public static Item success(String messageId) { + return new Item(true, Optional.ofNullable(messageId), Optional.empty()); + } + + /** Failed item. */ + public static Item failure(String errorCode) { + return new Item(false, Optional.empty(), Optional.of(errorCode)); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmContactPointUpdater.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmContactPointUpdater.java new file mode 100644 index 00000000..5c21bd71 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmContactPointUpdater.java @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm; + +import dev.caskeleton.application.notification.platform.api.ContactPointId; +import dev.caskeleton.application.notification.platform.api.TenantId; +import dev.caskeleton.application.notification.platform.contact.ContactPointStatus; +import dev.caskeleton.application.notification.platform.dispatch.ContactPointStorePort; +import java.util.Objects; + +/** + * Applies FCM target lifecycle changes. + * + *

An {@code UNREGISTERED} response is the provider telling us the target no longer exists. Not + * acting on it means every future notification to that user spends a provider call to learn the + * same thing again. + */ +public final class FcmContactPointUpdater { + + private final ContactPointStorePort contactPoints; + private final FcmFailureClassifier classifier; + + public FcmContactPointUpdater( + ContactPointStorePort contactPoints, FcmFailureClassifier classifier) { + this.contactPoints = Objects.requireNonNull(contactPoints, "contactPoints"); + this.classifier = Objects.requireNonNull(classifier, "classifier"); + } + + /** Invalidate the contact point when the error code says the target is gone. */ + public boolean apply(TenantId tenantId, ContactPointId contactPointId, String errorCode) { + Objects.requireNonNull(tenantId, "tenantId"); + Objects.requireNonNull(contactPointId, "contactPointId"); + Objects.requireNonNull(errorCode, "errorCode"); + if (!classifier.invalidatesContactPoint(errorCode)) { + return false; + } + contactPoints.updateStatus( + tenantId, contactPointId, ContactPointStatus.INVALID, "FCM_" + errorCode); + return true; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmFailureClassifier.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmFailureClassifier.java new file mode 100644 index 00000000..d6bf2e8b --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmFailureClassifier.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm; + +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.provider.ProviderFailure; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; +import java.time.Duration; +import java.util.Optional; + +/** + * FCM error codes to the stable failure vocabulary. + * + *

{@code UNREGISTERED} is the one that must never be retried: the target is gone, and repeating + * the call cannot bring it back. It invalidates the contact point and lets routing fall back. + */ +public final class FcmFailureClassifier { + + /** Classify one FCM error code. */ + public ProviderSubmissionResult classify(String errorCode, Duration elapsed) { + return ProviderSubmissionResult.rejected(failure(errorCode), elapsed); + } + + /** Failure for one FCM error code. */ + public ProviderFailure failure(String errorCode) { + return switch (errorCode) { + case "UNREGISTERED", "INVALID_TOKEN" -> + ProviderFailure.of( + NotificationFailureCode.CONTACT_POINT_INVALID, + FailureCategory.INVALID_RECIPIENT, + false); + case "QUOTA_EXCEEDED" -> + ProviderFailure.of( + NotificationFailureCode.PROVIDER_THROTTLED, FailureCategory.THROTTLED, true); + case "UNAVAILABLE", "INTERNAL" -> + ProviderFailure.of( + NotificationFailureCode.PROVIDER_TRANSIENT_FAILURE, + FailureCategory.TRANSIENT_PROVIDER, + true); + case "INVALID_ARGUMENT" -> + ProviderFailure.of( + NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD, false); + case "THIRD_PARTY_AUTH_ERROR", "UNAUTHENTICATED" -> + ProviderFailure.of( + NotificationFailureCode.PROVIDER_AUTHENTICATION_FAILED, + FailureCategory.AUTHENTICATION, + false); + case "SENDER_ID_MISMATCH" -> + ProviderFailure.of( + NotificationFailureCode.PROVIDER_AUTHORIZATION_FAILED, + FailureCategory.AUTHORIZATION, + false); + default -> + ProviderFailure.of( + NotificationFailureCode.PROVIDER_PERMANENT_FAILURE, + FailureCategory.PERMANENT_PROVIDER, + false); + }; + } + + /** Whether an error code means the contact point should be invalidated. */ + public boolean invalidatesContactPoint(String errorCode) { + return failure(errorCode).category() == FailureCategory.INVALID_RECIPIENT; + } + + /** Retry hint, where FCM supplies one. */ + public Optional retryAfter(Optional headerValue) { + return headerValue.flatMap( + value -> { + try { + return Optional.of(Duration.ofSeconds(Long.parseLong(value.trim()))); + } catch (NumberFormatException notSeconds) { + return Optional.empty(); + } + }); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmGateway.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmGateway.java new file mode 100644 index 00000000..79c6cb09 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmGateway.java @@ -0,0 +1,12 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm; + +import java.util.List; +import java.util.Map; + +/** The FCM transport seam, so batch decomposition can be tested without a live project. */ +@FunctionalInterface +public interface FcmGateway { + + /** Send a batch and return one positional result per message. */ + FcmBatchResult sendBatch(List> messages); +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmMessageMapper.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmMessageMapper.java new file mode 100644 index 00000000..d65001b2 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmMessageMapper.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm; + +import dev.caskeleton.application.notification.platform.api.content.MobilePushContent; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import java.time.Clock; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Builds the FCM message body. + * + *

TTL is the minimum of the remaining delivery deadline and the provider maximum. Sending the + * provider maximum when the notification expires in ninety seconds would let FCM keep retrying a + * message the platform has already given up on. + */ +public final class FcmMessageMapper { + + private static final int MAX_PAYLOAD_BYTES = 4096; + + private final FcmProviderProperties properties; + private final Clock clock; + + public FcmMessageMapper(FcmProviderProperties properties, Clock clock) { + this.properties = Objects.requireNonNull(properties, "properties"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + /** Message body for one submission. */ + public Map map(ProviderSubmission submission, FcmWireTarget target) { + Objects.requireNonNull(submission, "submission"); + Objects.requireNonNull(target, "target"); + if (!(submission.content().content() instanceof MobilePushContent push)) { + throw new IllegalArgumentException("FCM requires mobile push content"); + } + + Map message = new LinkedHashMap<>(); + if ("FID".equals(target.kind())) { + message.put("installation_id", target.value()); + } else { + message.put("token", target.value()); + } + message.put("notification", Map.of("title", push.title(), "body", push.body())); + if (!push.data().isEmpty()) { + message.put("data", push.data()); + } + + Map android = new LinkedHashMap<>(); + android.put("ttl", ttl(submission).toSeconds() + "s"); + submission.collapse().ifPresent(spec -> android.put("collapse_key", spec.key())); + message.put("android", android); + + return Map.of("message", message); + } + + /** Effective TTL for a submission. */ + public Duration ttl(ProviderSubmission submission) { + Optional remaining = + submission.expiresAt().map(expiry -> Duration.between(clock.instant(), expiry)); + return remaining + .filter(value -> value.compareTo(properties.maxTtl()) < 0) + .filter(value -> !value.isNegative()) + .orElse(properties.maxTtl()); + } + + /** Payload ceiling enforced before the provider call. */ + public int maxPayloadBytes() { + return MAX_PAYLOAD_BYTES; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmNotificationProviderAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmNotificationProviderAdapter.java new file mode 100644 index 00000000..1d3722c1 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmNotificationProviderAdapter.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm; + +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.provider.BatchNotificationProviderAdapter; +import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletionStage; + +/** + * FCM adapter. + * + *

A successful send means FCM took the message. Firebase describes its own failures as handoff + * failures, which is the clearest statement that success is a handoff and not a device delivery, so + * the strongest evidence this adapter ever produces is {@code PROVIDER_ACCEPTED}. + */ +public final class FcmNotificationProviderAdapter implements BatchNotificationProviderAdapter { + + private static final ProviderId PROVIDER_ID = new ProviderId("fcm"); + + private final FcmBatchCoordinator coordinator; + private final FcmProviderProperties properties; + + public FcmNotificationProviderAdapter( + FcmBatchCoordinator coordinator, FcmProviderProperties properties) { + this.coordinator = Objects.requireNonNull(coordinator, "coordinator"); + this.properties = Objects.requireNonNull(properties, "properties"); + } + + @Override + public ProviderId providerId() { + return PROVIDER_ID; + } + + @Override + public Set channels() { + return Set.of(Channel.PUSH); + } + + @Override + public ProviderCapabilities capabilities() { + // deliveryReceipt is false: FCM has no server-side delivery receipt for ordinary sends, and + // claiming one would let the runtime plan a reconciliation that can never succeed. + return new ProviderCapabilities( + true, + false, + false, + false, + false, + false, + false, + true, + properties.maxBatchSize(), + 4096L, + properties.maxTtl()); + } + + @Override + public CompletionStage submit(ProviderSubmission submission) { + Objects.requireNonNull(submission, "submission"); + return coordinator.submit(List.of(submission)).thenApply(results -> results.get(0)); + } + + @Override + public CompletionStage> submitBatch( + List submissions) { + return coordinator.submit(submissions); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmProviderProperties.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmProviderProperties.java new file mode 100644 index 00000000..a6bd37f1 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmProviderProperties.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm; + +import java.net.URI; +import java.time.Duration; +import java.util.Objects; + +/** FCM profile. Project and application identity are pinned so a target cannot cross projects. */ +public record FcmProviderProperties( + URI endpoint, + String projectId, + String applicationId, + int maxBatchSize, + Duration maxTtl, + Duration timeout) { + + /** The Admin SDK multicast ceiling. */ + public static final int MAX_SUPPORTED_BATCH = 500; + + public FcmProviderProperties { + Objects.requireNonNull(endpoint, "endpoint"); + Objects.requireNonNull(projectId, "projectId"); + Objects.requireNonNull(applicationId, "applicationId"); + Objects.requireNonNull(maxTtl, "maxTtl"); + Objects.requireNonNull(timeout, "timeout"); + if (projectId.isBlank() || applicationId.isBlank()) { + throw new IllegalArgumentException("projectId and applicationId must not be blank"); + } + if (maxBatchSize < 1 || maxBatchSize > MAX_SUPPORTED_BATCH) { + throw new IllegalArgumentException("maxBatchSize must be 1.." + MAX_SUPPORTED_BATCH); + } + if (timeout.isNegative() || timeout.isZero()) { + throw new IllegalArgumentException("timeout must be positive and finite"); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmTargetMapper.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmTargetMapper.java new file mode 100644 index 00000000..aa8fe01c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmTargetMapper.java @@ -0,0 +1,18 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm; + +import dev.caskeleton.application.notification.platform.contact.ContactPointValue; +import dev.caskeleton.application.notification.platform.contact.FcmInstallationId; +import dev.caskeleton.application.notification.platform.contact.LegacyFcmRegistrationToken; + +/** Maps typed push targets to their FCM wire representation. */ +public final class FcmTargetMapper { + + /** Wire target for a contact point value. */ + public FcmWireTarget map(ContactPointValue value) { + return switch (value) { + case FcmInstallationId fid -> new FcmWireTarget("FID", fid.value()); + case LegacyFcmRegistrationToken token -> new FcmWireTarget("LEGACY_TOKEN", token.value()); + default -> throw new IllegalArgumentException("FCM requires an FCM target"); + }; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmWireTarget.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmWireTarget.java new file mode 100644 index 00000000..878fbf7f --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmWireTarget.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm; + +import java.util.Objects; + +/** + * A target in its wire form, with the kind kept explicit. + * + *

The kind is not cosmetic: an installation id and a legacy registration token go to different + * request fields, and flattening them would make a migration a runtime guess. + */ +public record FcmWireTarget(String kind, String value) { + + public FcmWireTarget { + Objects.requireNonNull(kind, "kind"); + Objects.requireNonNull(value, "value"); + if (value.isBlank()) { + throw new IllegalArgumentException("value"); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/JdkNotificationHttpGateway.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/JdkNotificationHttpGateway.java new file mode 100644 index 00000000..e72be549 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/JdkNotificationHttpGateway.java @@ -0,0 +1,103 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.http; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Default gateway on the JDK HTTP client. + * + *

Redirects are never followed. A provider redirect would move a signed, credential-bearing + * request to a host the profile never approved. + * + *

Timeout and connection-reset failures are translated into an explicit statement about whether + * the body was committed, because that single bit is what separates a safe retry from a duplicate. + */ +public final class JdkNotificationHttpGateway implements NotificationHttpGateway { + + // Restricted headers the JDK client refuses to let a caller set. + private static final Set RESTRICTED = + Set.of("connection", "content-length", "expect", "host", "upgrade"); + + private final HttpClient client; + + public JdkNotificationHttpGateway(Duration connectTimeout) { + this( + HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER) + .connectTimeout(Objects.requireNonNull(connectTimeout, "connectTimeout")) + .build()); + } + + public JdkNotificationHttpGateway(HttpClient client) { + this.client = Objects.requireNonNull(client, "client"); + } + + @Override + public NotificationHttpResponse exchange(NotificationHttpRequest request) { + Objects.requireNonNull(request, "request"); + HttpRequest.Builder builder = + HttpRequest.newBuilder(request.uri()) + .timeout(request.timeout()) + .method(request.method(), HttpRequest.BodyPublishers.ofByteArray(request.body())); + request + .headers() + .forEach( + (name, values) -> { + if (!RESTRICTED.contains(name)) { + values.forEach(value -> builder.header(name, value)); + } + }); + + try { + HttpResponse response = + client.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray()); + return new NotificationHttpResponse( + response.statusCode(), Map.copyOf(response.headers().map()), response.body()); + } catch (HttpTimeoutException timeout) { + // The request timed out after the body was published, so the provider may well have it. + throw new NotificationHttpTransportException("RESPONSE_TIMEOUT", true, timeout); + } catch (IOException failure) { + throw new NotificationHttpTransportException( + "TRANSPORT_FAILURE", bodyWasLikelyCommitted(failure), failure); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new NotificationHttpTransportException("INTERRUPTED", true, interrupted); + } + } + + /** + * A connect failure happens before anything is written; anything else may have written the body. + * + *

The default is deliberately the pessimistic one: guessing "not committed" would turn an + * unknown into an automatic resend. + */ + private static boolean bodyWasLikelyCommitted(IOException failure) { + String message = failure.getMessage(); + if (message == null) { + return true; + } + String normalized = message.toLowerCase(java.util.Locale.ROOT); + boolean beforeSend = + normalized.contains("connection refused") + || normalized.contains("unresolved") + || normalized.contains("no route to host") + || normalized.contains("connect timed out"); + return !beforeSend; + } + + /** Header map helper for adapters. */ + public static Map> headers(Map singleValued) { + return singleValued.entrySet().stream() + .collect( + java.util.stream.Collectors.toUnmodifiableMap( + Map.Entry::getKey, entry -> List.of(entry.getValue()))); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationEndpoints.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationEndpoints.java new file mode 100644 index 00000000..9bc735bc --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationEndpoints.java @@ -0,0 +1,43 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.http; + +import java.net.URI; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** Endpoint validation shared by the provider profiles. */ +public final class NotificationEndpoints { + + private static final Set LOOPBACK_HOSTS = Set.of("127.0.0.1", "::1", "localhost"); + + private NotificationEndpoints() {} + + /** + * Require TLS, except on the loopback interface. + * + *

The exception is narrow on purpose. A plaintext provider endpoint on a routable host exposes + * credentials and message bodies to anything on the path, which is why it is refused outright. A + * loopback endpoint never leaves the machine, so the same reasoning does not apply — and without + * this the contract suite could not exercise a real socket at all, which would mean the ambiguity + * behaviour it exists to prove went untested. + */ + public static URI requireSecureOrLoopback(URI endpoint, String name) { + Objects.requireNonNull(endpoint, name); + String scheme = + endpoint.getScheme() == null ? "" : endpoint.getScheme().toLowerCase(Locale.ROOT); + if ("https".equals(scheme)) { + return endpoint; + } + String host = endpoint.getHost() == null ? "" : endpoint.getHost().toLowerCase(Locale.ROOT); + if ("http".equals(scheme) && LOOPBACK_HOSTS.contains(host)) { + return endpoint; + } + throw new IllegalArgumentException(name + " must use https outside the loopback interface"); + } + + /** Whether an endpoint is on the loopback interface. */ + public static boolean isLoopback(URI endpoint) { + String host = endpoint.getHost() == null ? "" : endpoint.getHost().toLowerCase(Locale.ROOT); + return LOOPBACK_HOSTS.contains(host); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpGateway.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpGateway.java new file mode 100644 index 00000000..a0c19265 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpGateway.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.http; + +/** + * The only way a provider adapter in this leaf reaches the network. + * + *

It exists as a port because the registry does not permit {@code adapter-outbound-notification + * → adapter-outbound-httpclient}. The composition root sees both leaves and is the supported place + * to substitute an implementation backed by the HTTP Client Platform, which brings its own TLS, + * circuit breaker, SSRF and dynamic-target policy. + */ +public interface NotificationHttpGateway { + + /** + * Execute one request. + * + * @throws NotificationHttpTransportException when no response could be read; the exception states + * whether the request body was already committed + */ + NotificationHttpResponse exchange(NotificationHttpRequest request); +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpRequest.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpRequest.java new file mode 100644 index 00000000..1123dfbf --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpRequest.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.http; + +import java.net.URI; +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** One outbound provider HTTP request. */ +@SuppressWarnings("ArrayRecordComponent") // defensive copies on construction and on every accessor +public record NotificationHttpRequest( + String method, URI uri, Map> headers, byte[] body, Duration timeout) { + + public NotificationHttpRequest { + Objects.requireNonNull(method, "method"); + Objects.requireNonNull(uri, "uri"); + Objects.requireNonNull(headers, "headers"); + Objects.requireNonNull(body, "body"); + Objects.requireNonNull(timeout, "timeout"); + if (timeout.isNegative() || timeout.isZero()) { + throw new IllegalArgumentException("timeout must be finite and positive"); + } + headers = + headers.entrySet().stream() + .collect( + java.util.stream.Collectors.toUnmodifiableMap( + entry -> entry.getKey().toLowerCase(Locale.ROOT), + entry -> List.copyOf(entry.getValue()))); + body = body.clone(); + } + + @Override + public byte[] body() { + return body.clone(); + } + + @Override + public boolean equals(Object other) { + return other instanceof NotificationHttpRequest request + && method.equals(request.method) + && uri.equals(request.uri) + && headers.equals(request.headers) + && Arrays.equals(body, request.body) + && timeout.equals(request.timeout); + } + + @Override + public int hashCode() { + return Objects.hash(method, uri, headers, Arrays.hashCode(body), timeout); + } + + @Override + public String toString() { + // The URI is redacted because a Web Push endpoint is a capability URL and the request body may + // be a rendered message. + return "NotificationHttpRequest[method=" + + method + + ", uri=redacted, bytes=" + + body.length + + "]"; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpResponse.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpResponse.java new file mode 100644 index 00000000..de43312c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpResponse.java @@ -0,0 +1,66 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.http; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** One provider HTTP response. */ +@SuppressWarnings("ArrayRecordComponent") // defensive copies on construction and on every accessor +public record NotificationHttpResponse( + int statusCode, Map> headers, byte[] body) { + + public NotificationHttpResponse { + Objects.requireNonNull(headers, "headers"); + Objects.requireNonNull(body, "body"); + headers = + headers.entrySet().stream() + .collect( + java.util.stream.Collectors.toUnmodifiableMap( + entry -> entry.getKey().toLowerCase(Locale.ROOT), + entry -> List.copyOf(entry.getValue()))); + body = body.clone(); + } + + @Override + public byte[] body() { + return body.clone(); + } + + /** Body decoded as UTF-8. */ + public String bodyAsString() { + return new String(body, StandardCharsets.UTF_8); + } + + /** First value of a header, matched case-insensitively. */ + public Optional header(String name) { + List values = headers.get(name.toLowerCase(Locale.ROOT)); + return values == null || values.isEmpty() ? Optional.empty() : Optional.of(values.get(0)); + } + + /** Whether the status is 2xx. */ + public boolean isSuccessful() { + return statusCode >= 200 && statusCode < 300; + } + + @Override + public boolean equals(Object other) { + return other instanceof NotificationHttpResponse response + && statusCode == response.statusCode + && headers.equals(response.headers) + && Arrays.equals(body, response.body); + } + + @Override + public int hashCode() { + return Objects.hash(statusCode, headers, Arrays.hashCode(body)); + } + + @Override + public String toString() { + return "NotificationHttpResponse[status=" + statusCode + ", bytes=" + body.length + "]"; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpTransportException.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpTransportException.java new file mode 100644 index 00000000..750d6511 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpTransportException.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.http; + +import java.util.Objects; + +/** + * A provider HTTP call that produced no usable response. + * + *

{@code requestBodyCommitted} is the field that decides everything downstream: a connection + * that failed before the body was written is a safe retry, while one that failed after it was + * written is an ambiguous submission that must not be resent automatically. + */ +public class NotificationHttpTransportException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final boolean requestBodyCommitted; + private final String reasonCode; + + public NotificationHttpTransportException( + String reasonCode, boolean requestBodyCommitted, Throwable cause) { + super(reasonCode, cause); + this.reasonCode = Objects.requireNonNull(reasonCode, "reasonCode"); + this.requestBodyCommitted = requestBodyCommitted; + } + + /** Whether the request body reached the provider before the failure. */ + public boolean requestBodyCommitted() { + return requestBodyCommitted; + } + + /** Bounded reason code, safe for logs and metrics. */ + public String reasonCode() { + return reasonCode; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/AwsSignatureV4Signer.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/AwsSignatureV4Signer.java new file mode 100644 index 00000000..91f5f16d --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/AwsSignatureV4Signer.java @@ -0,0 +1,147 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.HexFormat; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * AWS Signature Version 4. + * + *

Implemented here rather than pulled in with an SDK because the SDK would also bring its own + * HTTP client, retry policy and credential chain — three things this platform already owns and + * whose duplication would quietly move retry ownership out of the notification retry policy. + */ +public final class AwsSignatureV4Signer { + + private static final String ALGORITHM = "AWS4-HMAC-SHA256"; + private static final DateTimeFormatter AMZ_DATE = + DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC); + private static final DateTimeFormatter DATE_STAMP = + DateTimeFormatter.ofPattern("yyyyMMdd").withZone(ZoneOffset.UTC); + + /** Signed headers to add to a request. */ + public record SignedHeaders(String authorization, String amzDate, String contentSha256) { + + public SignedHeaders { + Objects.requireNonNull(authorization, "authorization"); + Objects.requireNonNull(amzDate, "amzDate"); + Objects.requireNonNull(contentSha256, "contentSha256"); + } + } + + /** Sign one request. */ + public SignedHeaders sign( + String method, + String canonicalUri, + String canonicalQuery, + Map headers, + byte[] body, + String accessKeyId, + byte[] secretAccessKey, + String region, + String service, + Instant signedAt) { + Objects.requireNonNull(method, "method"); + Objects.requireNonNull(canonicalUri, "canonicalUri"); + Objects.requireNonNull(canonicalQuery, "canonicalQuery"); + Objects.requireNonNull(headers, "headers"); + Objects.requireNonNull(body, "body"); + Objects.requireNonNull(signedAt, "signedAt"); + + String amzDate = AMZ_DATE.format(signedAt); + String dateStamp = DATE_STAMP.format(signedAt); + String payloadHash = hex(sha256(body)); + + TreeMap canonicalHeaders = new TreeMap<>(); + headers.forEach( + (name, value) -> canonicalHeaders.put(name.toLowerCase(Locale.ROOT), value.trim())); + canonicalHeaders.put("x-amz-date", amzDate); + canonicalHeaders.put("x-amz-content-sha256", payloadHash); + + StringBuilder canonicalHeaderBlock = new StringBuilder(); + canonicalHeaders.forEach( + (name, value) -> canonicalHeaderBlock.append(name).append(':').append(value).append('\n')); + String signedHeaderNames = String.join(";", canonicalHeaders.keySet()); + + String canonicalRequest = + method + + '\n' + + canonicalUri + + '\n' + + canonicalQuery + + '\n' + + canonicalHeaderBlock + + '\n' + + signedHeaderNames + + '\n' + + payloadHash; + + String credentialScope = dateStamp + "/" + region + "/" + service + "/aws4_request"; + String stringToSign = + ALGORITHM + + '\n' + + amzDate + + '\n' + + credentialScope + + '\n' + + hex(sha256(canonicalRequest.getBytes(StandardCharsets.UTF_8))); + + byte[] signingKey = signingKey(secretAccessKey, dateStamp, region, service); + String signature = hex(hmac(signingKey, stringToSign.getBytes(StandardCharsets.UTF_8))); + + String authorization = + ALGORITHM + + " Credential=" + + accessKeyId + + "/" + + credentialScope + + ", SignedHeaders=" + + signedHeaderNames + + ", Signature=" + + signature; + return new SignedHeaders(authorization, amzDate, payloadHash); + } + + private static byte[] signingKey( + byte[] secretAccessKey, String dateStamp, String region, String service) { + byte[] key = + ("AWS4" + new String(secretAccessKey, StandardCharsets.UTF_8)) + .getBytes(StandardCharsets.UTF_8); + byte[] dateKey = hmac(key, dateStamp.getBytes(StandardCharsets.UTF_8)); + byte[] regionKey = hmac(dateKey, region.getBytes(StandardCharsets.UTF_8)); + byte[] serviceKey = hmac(regionKey, service.getBytes(StandardCharsets.UTF_8)); + return hmac(serviceKey, "aws4_request".getBytes(StandardCharsets.UTF_8)); + } + + private static byte[] hmac(byte[] key, byte[] data) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return mac.doFinal(data); + } catch (java.security.GeneralSecurityException failure) { + throw new IllegalStateException("HmacSHA256 is required by the Java platform", failure); + } + } + + private static byte[] sha256(byte[] value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException("SHA-256 is required by the Java platform", failure); + } + } + + private static String hex(byte[] value) { + return HexFormat.of().formatHex(value); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesCallbackAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesCallbackAdapter.java new file mode 100644 index 00000000..61494c7e --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesCallbackAdapter.java @@ -0,0 +1,82 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.callback.CallbackRequest; +import dev.caskeleton.application.notification.platform.callback.CallbackVerificationResult; +import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent; +import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapter; +import dev.caskeleton.application.notification.platform.callback.VerifiedCallback; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import tools.jackson.databind.JsonNode; + +/** + * SES event ingestion over SNS. + * + *

A subscription confirmation is verified like any other message but produces no provider event: + * confirming a topic is an operational act, and letting it into the ledger would mean an unverified + * caller could add rows just by claiming to be SNS. + */ +public final class SesCallbackAdapter implements ProviderCallbackAdapter { + + private static final ProviderId PROVIDER_ID = new ProviderId("ses"); + + private final SnsSignatureVerifier verifier; + private final SesEventNormalizer normalizer; + + public SesCallbackAdapter(SnsSignatureVerifier verifier, SesEventNormalizer normalizer) { + this.verifier = Objects.requireNonNull(verifier, "verifier"); + this.normalizer = Objects.requireNonNull(normalizer, "normalizer"); + } + + @Override + public ProviderId providerId() { + return PROVIDER_ID; + } + + @Override + public CallbackVerificationResult verify(CallbackRequest request) { + Objects.requireNonNull(request, "request"); + Map envelope; + try { + envelope = flatten(new String(request.body(), StandardCharsets.UTF_8)); + } catch (RuntimeException unparseable) { + return CallbackVerificationResult.invalid("SNS_ENVELOPE_UNPARSEABLE"); + } + if (!verifier.isValid(envelope)) { + return CallbackVerificationResult.invalid("SNS_SIGNATURE_MISMATCH"); + } + return CallbackVerificationResult.valid(new VerifiedCallback(request, envelope)); + } + + @Override + public List normalize(VerifiedCallback callback) { + Objects.requireNonNull(callback, "callback"); + Map envelope = callback.canonicalParameters(); + String type = envelope.getOrDefault("Type", "Notification"); + if (!"Notification".equals(type)) { + // Confirmations and unsubscribes are handled by operations, not by the delivery ledger. + return List.of(); + } + String message = envelope.getOrDefault("Message", "{}"); + return normalizer.normalize(message); + } + + private static Map flatten(String body) { + JsonNode root = NotificationJsonMapper.mapper().readTree(body); + Map envelope = new LinkedHashMap<>(); + root.properties() + .forEach( + property -> { + JsonNode value = property.getValue(); + if (value != null && value.isValueNode()) { + envelope.put(property.getKey(), value.asString()); + } + }); + return envelope; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesDeliveryProjector.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesDeliveryProjector.java new file mode 100644 index 00000000..e0c7ac2c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesDeliveryProjector.java @@ -0,0 +1,18 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.callback.StandardDeliveryProjector; + +/** + * SES projector. + * + *

SES adds no transition of its own: delivery, bounce and complaint all obey the shared table, + * and the interesting SES-specific behaviour — a complaint arriving after a delivery — is exactly + * what the shared table already gets right. + */ +public final class SesDeliveryProjector extends StandardDeliveryProjector { + + public SesDeliveryProjector() { + super(new ProviderId("ses")); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesEventNormalizer.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesEventNormalizer.java new file mode 100644 index 00000000..eba30d54 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesEventNormalizer.java @@ -0,0 +1,101 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.callback.NormalizedEventType; +import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import tools.jackson.databind.JsonNode; + +/** + * SES event publishing to the stable vocabulary. + * + *

Bounce type decides the suppression consequence: a permanent bounce invalidates the address, + * while a transient one is a retry input. Collapsing both into one reason would remove an address + * because a mailbox was briefly full. + */ +public final class SesEventNormalizer { + + /** Normalize one SES notification payload. */ + public List normalize(String payload) { + JsonNode root = NotificationJsonMapper.mapper().readTree(payload); + String type = text(root, "eventType").orElse(text(root, "notificationType").orElse("Unknown")); + Optional messageId = + Optional.ofNullable(root.get("mail")).flatMap(mail -> text(mail, "messageId")); + Optional occurredAt = timestamp(root, type); + + List events = new ArrayList<>(1); + events.add( + switch (type) { + case "Send" -> + event(NormalizedEventType.PROVIDER_ACCEPTED, type, messageId, occurredAt, Map.of()); + case "Delivery" -> + event(NormalizedEventType.DELIVERY_CONFIRMED, type, messageId, occurredAt, Map.of()); + case "DeliveryDelay" -> + event(NormalizedEventType.DELIVERY_DELAYED, type, messageId, occurredAt, Map.of()); + case "Bounce" -> bounce(root, type, messageId, occurredAt); + case "Complaint" -> + event(NormalizedEventType.COMPLAINT, type, messageId, occurredAt, Map.of()); + case "Reject" -> + event(NormalizedEventType.PROVIDER_REJECTED, type, messageId, occurredAt, Map.of()); + case "RenderingFailure" -> + event(NormalizedEventType.TEMPLATE_FAILURE, type, messageId, occurredAt, Map.of()); + case "Open" -> event(NormalizedEventType.OPENED, type, messageId, occurredAt, Map.of()); + case "Click" -> event(NormalizedEventType.CLICKED, type, messageId, occurredAt, Map.of()); + default -> event(NormalizedEventType.UNKNOWN, type, messageId, occurredAt, Map.of()); + }); + return List.copyOf(events); + } + + private static NormalizedProviderEvent bounce( + JsonNode root, String type, Optional messageId, Optional occurredAt) { + String bounceType = + Optional.ofNullable(root.get("bounce")) + .flatMap(bounce -> text(bounce, "bounceType")) + .orElse("Undetermined"); + NormalizedEventType normalized = + "Permanent".equals(bounceType) + ? NormalizedEventType.BOUNCED_HARD + : NormalizedEventType.BOUNCED_SOFT; + return event( + normalized, + type + "/" + bounceType, + messageId, + occurredAt, + Map.of("bounceType", bounceType)); + } + + private static NormalizedProviderEvent event( + NormalizedEventType type, + String nativeType, + Optional messageId, + Optional occurredAt, + Map attributes) { + return new NormalizedProviderEvent( + type, nativeType, Optional.empty(), messageId, occurredAt, attributes); + } + + private static Optional text(JsonNode node, String field) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? Optional.empty() : Optional.of(value.asString()); + } + + private static Optional timestamp(JsonNode root, String type) { + JsonNode section = root.get(type.toLowerCase(java.util.Locale.ROOT)); + if (section == null) { + return Optional.empty(); + } + return text(section, "timestamp") + .flatMap( + value -> { + try { + return Optional.of(Instant.parse(value)); + } catch (java.time.format.DateTimeParseException unparseable) { + return Optional.empty(); + } + }); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesFailureClassifier.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesFailureClassifier.java new file mode 100644 index 00000000..cfe49e50 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesFailureClassifier.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.provider.ProviderFailure; +import java.util.Optional; + +/** Maps SES error responses onto the stable failure vocabulary. */ +public final class SesFailureClassifier { + + /** Classify a non-2xx SES response. */ + public ProviderFailure classify(NotificationHttpResponse response) { + String body = response.bodyAsString(); + if (body.contains("MessageRejected")) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_REJECTED, + FailureCategory.PERMANENT_PROVIDER, + false, + Optional.empty(), + Optional.of("MessageRejected")); + } + if (body.contains("MailFromDomainNotVerified") || body.contains("SendingPausedException")) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID, + FailureCategory.AUTHORIZATION, + false, + Optional.empty(), + Optional.of("SenderIdentityNotReady")); + } + if (body.contains("TooManyRequestsException") || response.statusCode() == 429) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_THROTTLED, + FailureCategory.THROTTLED, + true, + ProviderResults.retryAfter(response.header("retry-after")), + Optional.of("TooManyRequests")); + } + if (body.contains("AccountSuspendedException")) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_AUTHORIZATION_FAILED, + FailureCategory.AUTHORIZATION, + false, + Optional.empty(), + Optional.of("AccountSuspended")); + } + return ProviderResults.fromStatus( + response.statusCode(), ProviderResults.retryAfter(response.header("retry-after"))); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapter.java new file mode 100644 index 00000000..82874047 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapter.java @@ -0,0 +1,134 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException; +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.contact.ContactPointValue; +import dev.caskeleton.application.notification.platform.contact.EmailAddress; +import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; +import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; +import dev.caskeleton.application.notification.platform.security.AccessContext; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.time.Clock; +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +/** + * Amazon SES submission. + * + *

{@code MessageId} is stored as the provider request id and mapped to {@code + * PROVIDER_ACCEPTED}. SES documents that it can accept a request and still decline to send — for a + * virus finding or a bad personalisation — so promoting acceptance to delivery would be wrong by + * the provider's own contract, not merely conservative. + * + *

Retry ownership stays with the notification retry policy: the gateway performs no blind retry, + * because a resend after a lost response is exactly the decision the evidence model must make. + */ +public final class SesNotificationProviderAdapter implements NotificationProviderAdapter { + + private static final ProviderId PROVIDER_ID = new ProviderId("ses"); + + private final NotificationHttpGateway gateway; + private final SesRequestMapper mapper; + private final SesFailureClassifier classifier; + private final ContactPointProtector protector; + private final SecretMaterialProvider secrets; + private final String accessKeyId; + private final Clock clock; + + public SesNotificationProviderAdapter( + NotificationHttpGateway gateway, + SesRequestMapper mapper, + SesFailureClassifier classifier, + ContactPointProtector protector, + SecretMaterialProvider secrets, + String accessKeyId, + Clock clock) { + this.gateway = Objects.requireNonNull(gateway, "gateway"); + this.mapper = Objects.requireNonNull(mapper, "mapper"); + this.classifier = Objects.requireNonNull(classifier, "classifier"); + this.protector = Objects.requireNonNull(protector, "protector"); + this.secrets = Objects.requireNonNull(secrets, "secrets"); + this.accessKeyId = Objects.requireNonNull(accessKeyId, "accessKeyId"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @Override + public ProviderId providerId() { + return PROVIDER_ID; + } + + @Override + public Set channels() { + return Set.of(Channel.EMAIL); + } + + @Override + public ProviderCapabilities capabilities() { + return new ProviderCapabilities( + false, false, true, false, false, false, false, false, 1, 10_000_000L, Duration.ofDays(1)); + } + + @Override + public CompletionStage submit(ProviderSubmission submission) { + Objects.requireNonNull(submission, "submission"); + return CompletableFuture.completedFuture(send(submission)); + } + + private ProviderSubmissionResult send(ProviderSubmission submission) { + long startedNanos = System.nanoTime(); + ContactPointValue value = + protector.reveal( + submission.contactPoint(), + AccessContext.dispatch(submission.profile().profileId().value())); + if (!(value instanceof EmailAddress address)) { + throw new IllegalArgumentException("SES requires an email contact point"); + } + + var request = + mapper.map( + submission, + address.normalized(), + accessKeyId, + secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material(), + clock.instant()); + + try { + NotificationHttpResponse response = gateway.exchange(request); + Duration elapsed = elapsedSince(startedNanos); + if (response.isSuccessful()) { + return ProviderSubmissionResult.accepted(messageId(response), "Accepted", elapsed); + } + return ProviderSubmissionResult.rejected(classifier.classify(response), elapsed); + } catch (NotificationHttpTransportException transportFailure) { + return ProviderResults.fromTransport(transportFailure, elapsedSince(startedNanos)); + } + } + + private static String messageId(NotificationHttpResponse response) { + try { + var node = NotificationJsonMapper.mapper().readTree(response.bodyAsString()); + return Optional.ofNullable(node.get("MessageId")).map(value -> value.asString()).orElse(null); + } catch (RuntimeException unparseable) { + // A 2xx without a parseable body is still acceptance; the platform simply has no provider + // request id to reconcile against later. + return null; + } + } + + private static Duration elapsedSince(long startedNanos) { + return Duration.ofNanos(System.nanoTime() - startedNanos); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesProviderProperties.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesProviderProperties.java new file mode 100644 index 00000000..8c354419 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesProviderProperties.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationEndpoints; +import java.net.URI; +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; + +/** SES profile. Configuration sets are approved N3 options, not free-form provider parameters. */ +public record SesProviderProperties( + URI endpoint, + String region, + String senderIdentity, + Optional configurationSet, + Duration timeout) { + + public SesProviderProperties { + Objects.requireNonNull(endpoint, "endpoint"); + Objects.requireNonNull(region, "region"); + Objects.requireNonNull(senderIdentity, "senderIdentity"); + Objects.requireNonNull(configurationSet, "configurationSet"); + Objects.requireNonNull(timeout, "timeout"); + NotificationEndpoints.requireSecureOrLoopback(endpoint, "SES endpoint"); + if (region.isBlank() || senderIdentity.isBlank()) { + throw new IllegalArgumentException("region and senderIdentity must not be blank"); + } + if (timeout.isNegative() || timeout.isZero()) { + throw new IllegalArgumentException("timeout must be positive and finite"); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesRequestMapper.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesRequestMapper.java new file mode 100644 index 00000000..42ee9811 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesRequestMapper.java @@ -0,0 +1,86 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest; +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.api.content.EmailContent; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** Builds the signed SES v2 send request. */ +public final class SesRequestMapper { + + private static final String PATH = "/v2/email/outbound-emails"; + + private final SesProviderProperties properties; + private final AwsSignatureV4Signer signer; + + public SesRequestMapper(SesProviderProperties properties, AwsSignatureV4Signer signer) { + this.properties = Objects.requireNonNull(properties, "properties"); + this.signer = Objects.requireNonNull(signer, "signer"); + } + + /** Map one submission into a signed request. */ + public NotificationHttpRequest map( + ProviderSubmission submission, + String recipientAddress, + String accessKeyId, + byte[] secretAccessKey, + Instant signedAt) { + Objects.requireNonNull(submission, "submission"); + if (!(submission.content().content() instanceof EmailContent email)) { + throw new IllegalArgumentException("SES requires email content"); + } + + Map simple = new LinkedHashMap<>(); + simple.put("Subject", Map.of("Data", email.subject(), "Charset", "UTF-8")); + Map bodyParts = new LinkedHashMap<>(); + bodyParts.put("Text", Map.of("Data", email.textBody(), "Charset", "UTF-8")); + email + .htmlBody() + .ifPresent(html -> bodyParts.put("Html", Map.of("Data", html, "Charset", "UTF-8"))); + simple.put("Body", bodyParts); + + Map payload = new LinkedHashMap<>(); + payload.put("FromEmailAddress", properties.senderIdentity()); + payload.put("Destination", Map.of("ToAddresses", java.util.List.of(recipientAddress))); + payload.put("Content", Map.of("Simple", simple)); + properties.configurationSet().ifPresent(name -> payload.put("ConfigurationSetName", name)); + + byte[] body = + NotificationJsonMapper.mapper() + .writeValueAsString(payload) + .getBytes(StandardCharsets.UTF_8); + String host = properties.endpoint().getHost(); + + var signed = + signer.sign( + "POST", + PATH, + "", + Map.of("host", host, "content-type", "application/json"), + body, + accessKeyId, + secretAccessKey, + properties.region(), + "ses", + signedAt); + + return new NotificationHttpRequest( + "POST", + URI.create(properties.endpoint().toString() + PATH), + JdkNotificationHttpGateway.headers( + Map.of( + "content-type", "application/json", + "x-amz-date", signed.amzDate(), + "x-amz-content-sha256", signed.contentSha256(), + "authorization", signed.authorization())), + body, + properties.timeout()); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesSuppressionUpdater.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesSuppressionUpdater.java new file mode 100644 index 00000000..9a3d02df --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesSuppressionUpdater.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot; +import dev.caskeleton.application.notification.platform.callback.NormalizedEventType; +import dev.caskeleton.application.notification.platform.callback.NotificationSideEffectPort; +import dev.caskeleton.application.notification.platform.callback.SuppressionFacts; +import java.util.Objects; + +/** + * Turns an SES event into the suppression fact it implies. + * + *

A permanent bounce and a transient one map to different facts on purpose: treating a full + * mailbox like a dead address removes a recipient who would have received the next message fine. + */ +public final class SesSuppressionUpdater { + + private final NotificationSideEffectPort sideEffects; + + public SesSuppressionUpdater(NotificationSideEffectPort sideEffects) { + this.sideEffects = Objects.requireNonNull(sideEffects, "sideEffects"); + } + + /** Apply the suppression consequence of one normalized event. */ + public boolean apply(DeliveryAttemptSnapshot attempt, NormalizedEventType type) { + Objects.requireNonNull(attempt, "attempt"); + Objects.requireNonNull(type, "type"); + + SuppressionFacts facts = + switch (type) { + case BOUNCED_HARD -> SuppressionFacts.NONE.withHardBounce().withInvalidTarget(); + case COMPLAINT -> SuppressionFacts.NONE.withComplaint(); + case INVALID_RECIPIENT -> SuppressionFacts.NONE.withInvalidTarget(); + // A soft bounce is explicitly not a suppression: it is a retry input. + default -> SuppressionFacts.NONE; + }; + if (!facts.requiresSuppression()) { + return false; + } + sideEffects.apply(attempt, facts); + return true; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SnsCertificateProvider.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SnsCertificateProvider.java new file mode 100644 index 00000000..7791eda1 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SnsCertificateProvider.java @@ -0,0 +1,16 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import java.net.URI; +import java.security.PublicKey; + +/** + * Supplies the public key of an SNS signing certificate. + * + *

A port so the fetch, its cache and its TLS policy stay outside the verifier — and so a + * contract test can verify signatures without reaching the network. + */ +public interface SnsCertificateProvider { + + /** Public key of the certificate at a URL that has already been host-checked. */ + PublicKey publicKeyFor(URI certificateUrl); +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SnsSignatureVerifier.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SnsSignatureVerifier.java new file mode 100644 index 00000000..6d6b640c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SnsSignatureVerifier.java @@ -0,0 +1,108 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.PublicKey; +import java.security.Signature; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * SNS message signature verification. + * + *

Two checks, and both are load-bearing. The signing certificate URL is constrained to an + * Amazon-owned host before it is fetched, because a message that names its own certificate host is + * otherwise self-signed by whoever sent it. The canonical string is then rebuilt from the fields + * SNS specifies, in its order, because signing a re-serialised body would verify our own JSON + * writer rather than the message. + */ +public final class SnsSignatureVerifier { + + private static final Set NOTIFICATION_FIELDS = + Set.of("Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type"); + private static final Set SUBSCRIPTION_FIELDS = + Set.of("Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type"); + + private final SnsCertificateProvider certificates; + private final String certificateHostSuffix; + + public SnsSignatureVerifier(SnsCertificateProvider certificates, String certificateHostSuffix) { + this.certificates = Objects.requireNonNull(certificates, "certificates"); + this.certificateHostSuffix = + Objects.requireNonNull(certificateHostSuffix, "certificateHostSuffix"); + if (certificateHostSuffix.isBlank()) { + throw new IllegalArgumentException("certificateHostSuffix"); + } + } + + /** Verify one SNS envelope. */ + public boolean isValid(Map envelope) { + Objects.requireNonNull(envelope, "envelope"); + String certificateUrl = envelope.get("SigningCertURL"); + String signature = envelope.get("Signature"); + String version = envelope.getOrDefault("SignatureVersion", "1"); + if (certificateUrl == null || signature == null) { + return false; + } + if (!isTrustedCertificateUrl(certificateUrl)) { + return false; + } + + try { + PublicKey key = certificates.publicKeyFor(URI.create(certificateUrl)); + Signature verifier = + Signature.getInstance("2".equals(version) ? "SHA256withRSA" : "SHA1withRSA"); + verifier.initVerify(key); + verifier.update(canonicalString(envelope).getBytes(StandardCharsets.UTF_8)); + return verifier.verify(Base64.getDecoder().decode(signature)); + } catch (GeneralSecurityException | IllegalArgumentException failure) { + return false; + } + } + + /** Whether the certificate URL is on an Amazon host over TLS. */ + public boolean isTrustedCertificateUrl(String certificateUrl) { + try { + URI uri = URI.create(certificateUrl); + String host = uri.getHost() == null ? "" : uri.getHost().toLowerCase(Locale.ROOT); + return "https".equalsIgnoreCase(uri.getScheme()) && host.endsWith(certificateHostSuffix); + } catch (IllegalArgumentException malformed) { + return false; + } + } + + /** The exact field-name/value sequence SNS signs. */ + public static String canonicalString(Map envelope) { + Set fields = + "SubscriptionConfirmation".equals(envelope.get("Type")) + || "UnsubscribeConfirmation".equals(envelope.get("Type")) + ? SUBSCRIPTION_FIELDS + : NOTIFICATION_FIELDS; + + Map ordered = new LinkedHashMap<>(); + List.of( + "Message", + "MessageId", + "Subject", + "SubscribeURL", + "Timestamp", + "Token", + "TopicArn", + "Type") + .stream() + .filter(fields::contains) + .filter(envelope::containsKey) + .forEach(name -> ordered.put(name, envelope.get(name))); + + StringBuilder canonical = new StringBuilder(); + ordered.forEach( + (name, value) -> canonical.append(name).append('\n').append(value).append('\n')); + return canonical.toString(); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpDispatch.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpDispatch.java new file mode 100644 index 00000000..d68bd1c0 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpDispatch.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp; + +import jakarta.mail.internet.MimeMessage; + +/** + * The single SMTP send operation. + * + *

Extracted behind an interface so the adapter's classification rules can be exercised against + * every SMTP outcome — including a connection lost after {@code DATA} — without a live relay. + */ +@FunctionalInterface +public interface SmtpDispatch { + + /** + * Send one message. + * + * @throws SmtpDispatchException with the reply code, or with the fact that the body was already + * committed when the connection dropped + */ + void send(MimeMessage message); +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpDispatchException.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpDispatchException.java new file mode 100644 index 00000000..919be2cf --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpDispatchException.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp; + +import java.util.Objects; +import java.util.Optional; + +/** An SMTP send that did not complete with a final acceptance. */ +public class SmtpDispatchException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient Optional replyCode; + private final boolean dataCommitted; + + public SmtpDispatchException( + String reasonCode, Optional replyCode, boolean dataCommitted, Throwable cause) { + super(reasonCode, cause); + this.replyCode = Objects.requireNonNull(replyCode, "replyCode"); + this.dataCommitted = dataCommitted; + } + + /** SMTP reply code, when the server answered at all. */ + public Optional replyCode() { + return replyCode; + } + + /** Whether the message body had already been transmitted when the failure happened. */ + public boolean dataCommitted() { + return dataCommitted; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpFailureClassifier.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpFailureClassifier.java new file mode 100644 index 00000000..4aacc57a --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpFailureClassifier.java @@ -0,0 +1,81 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp; + +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.provider.ProviderExecutionEvidence; +import dev.caskeleton.application.notification.platform.provider.ProviderFailure; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; +import java.time.Duration; +import java.util.Optional; +import java.util.Set; + +/** + * RFC 5321 reply-code classification. + * + *

4yz is a temporary failure the client may repeat; 5yz is permanent and must not be repeated + * unchanged. The interesting case is neither: a connection lost after {@code DATA} means the relay + * may already hold the message, so {@code SMTP_SEND_FAILED = safe retry} is exactly the + * simplification this classifier exists to prevent. + */ +public final class SmtpFailureClassifier { + + /** Reply codes that identify the recipient rather than the transaction as the problem. */ + private static final Set INVALID_RECIPIENT_CODES = Set.of(550, 551, 553, 511); + + /** Classify a failed send. */ + public ProviderSubmissionResult classify(SmtpDispatchException failure, Duration elapsed) { + Optional replyCode = failure.replyCode(); + + if (replyCode.isEmpty()) { + if (failure.dataCommitted()) { + return ProviderSubmissionResult.ambiguous( + new ProviderFailure( + NotificationFailureCode.PROVIDER_RESPONSE_LOST, + FailureCategory.AMBIGUOUS_SUBMISSION, + false, + Optional.empty(), + Optional.of(failure.getMessage())), + ProviderExecutionEvidence.responseLost(), + elapsed); + } + return ProviderSubmissionResult.notSubmitted( + new ProviderFailure( + NotificationFailureCode.PROVIDER_TRANSIENT_FAILURE, + FailureCategory.TRANSIENT_PROVIDER, + true, + Optional.empty(), + Optional.of(failure.getMessage())), + elapsed); + } + + int code = replyCode.get(); + if (code >= 400 && code < 500) { + return ProviderSubmissionResult.rejected( + new ProviderFailure( + NotificationFailureCode.PROVIDER_TRANSIENT_FAILURE, + FailureCategory.TRANSIENT_PROVIDER, + true, + Optional.empty(), + Optional.of(Integer.toString(code))), + elapsed); + } + if (INVALID_RECIPIENT_CODES.contains(code)) { + return ProviderSubmissionResult.rejected( + new ProviderFailure( + NotificationFailureCode.CONTACT_POINT_INVALID, + FailureCategory.INVALID_RECIPIENT, + false, + Optional.empty(), + Optional.of(Integer.toString(code))), + elapsed); + } + return ProviderSubmissionResult.rejected( + new ProviderFailure( + NotificationFailureCode.PROVIDER_PERMANENT_FAILURE, + FailureCategory.PERMANENT_PROVIDER, + false, + Optional.empty(), + Optional.of(Integer.toString(code))), + elapsed); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpMimeMessageFactory.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpMimeMessageFactory.java new file mode 100644 index 00000000..89574384 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpMimeMessageFactory.java @@ -0,0 +1,95 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp; + +import dev.caskeleton.application.notification.platform.api.content.EmailContent; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.NotificationValidationException; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment; +import jakarta.mail.MessagingException; +import jakarta.mail.Session; +import jakarta.mail.internet.MimeMessage; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; +import org.springframework.mail.javamail.MimeMessageHelper; + +/** + * Builds the MIME message. + * + *

Text and HTML are assembled as {@code multipart/alternative} and everything is UTF-8. Header + * values containing CR or LF are rejected before the message is built: header injection is the one + * email failure that turns a notification into someone else's mail. + * + *

A MIME construction failure is a non-retryable rejection, and it happens before any relay is + * contacted. + */ +public final class SmtpMimeMessageFactory { + + private final Session session; + + public SmtpMimeMessageFactory(Session session) { + this.session = Objects.requireNonNull(session, "session"); + } + + /** Build a message for one submission. */ + public MimeMessage create( + ProviderSubmission submission, + String recipientAddress, + String fromAddress, + List attachments) { + Objects.requireNonNull(submission, "submission"); + Objects.requireNonNull(recipientAddress, "recipientAddress"); + Objects.requireNonNull(fromAddress, "fromAddress"); + Objects.requireNonNull(attachments, "attachments"); + + if (!(submission.content().content() instanceof EmailContent email)) { + throw rejection(); + } + requireHeaderSafe(recipientAddress); + requireHeaderSafe(fromAddress); + requireHeaderSafe(email.subject()); + + try { + MimeMessage message = new MimeMessage(session); + MimeMessageHelper helper = + new MimeMessageHelper( + message, + !attachments.isEmpty() || email.htmlBody().isPresent(), + StandardCharsets.UTF_8.name()); + helper.setFrom(fromAddress); + helper.setTo(recipientAddress); + helper.setSubject(email.subject()); + if (email.htmlBody().isPresent()) { + helper.setText(email.textBody(), email.htmlBody().get()); + } else { + helper.setText(email.textBody(), false); + } + for (ResolvedAttachment attachment : attachments) { + helper.addAttachment( + attachment.displayName(), () -> attachment.content(), attachment.contentType()); + } + for (var header : email.options().approvedHeaders().entrySet()) { + requireHeaderSafe(header.getKey()); + requireHeaderSafe(header.getValue()); + message.setHeader(header.getKey(), header.getValue()); + } + return message; + } catch (MessagingException failure) { + throw rejection(); + } + } + + private static void requireHeaderSafe(String value) { + if (value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\0') >= 0) { + throw rejection(); + } + } + + private static NotificationValidationException rejection() { + return new NotificationValidationException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD)); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpNotificationProviderAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpNotificationProviderAdapter.java new file mode 100644 index 00000000..5176fa40 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpNotificationProviderAdapter.java @@ -0,0 +1,102 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp; + +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.contact.ContactPointValue; +import dev.caskeleton.application.notification.platform.contact.EmailAddress; +import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; +import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; +import dev.caskeleton.application.notification.platform.security.AccessContext; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Executor; + +/** + * SMTP email adapter. + * + *

A final {@code 250} is provider acceptance and nothing more. The relay has taken + * responsibility for the message; whether it reaches an inbox is a separate question this adapter + * cannot answer, so the result carries {@code PROVIDER_ACCEPTED} and a delivery outcome of {@code + * UNKNOWN}. + */ +public final class SmtpNotificationProviderAdapter implements NotificationProviderAdapter { + + private static final ProviderId PROVIDER_ID = new ProviderId("smtp"); + + private final SmtpDispatch dispatch; + private final SmtpMimeMessageFactory mimeFactory; + private final SmtpFailureClassifier classifier; + private final ContactPointProtector protector; + private final SmtpProviderProperties properties; + private final Executor executor; + + public SmtpNotificationProviderAdapter( + SmtpDispatch dispatch, + SmtpMimeMessageFactory mimeFactory, + SmtpFailureClassifier classifier, + ContactPointProtector protector, + SmtpProviderProperties properties, + Executor executor) { + this.dispatch = Objects.requireNonNull(dispatch, "dispatch"); + this.mimeFactory = Objects.requireNonNull(mimeFactory, "mimeFactory"); + this.classifier = Objects.requireNonNull(classifier, "classifier"); + this.protector = Objects.requireNonNull(protector, "protector"); + this.properties = Objects.requireNonNull(properties, "properties"); + this.executor = Objects.requireNonNull(executor, "executor"); + } + + @Override + public ProviderId providerId() { + return PROVIDER_ID; + } + + @Override + public Set channels() { + return Set.of(Channel.EMAIL); + } + + @Override + public ProviderCapabilities capabilities() { + // SMTP offers no status callback, no status query and no provider-side idempotency, so the + // runtime must never plan a reconciliation for it. + return new ProviderCapabilities( + false, false, false, false, false, false, false, false, 1, 25_000_000L, Duration.ofDays(1)); + } + + @Override + public CompletionStage submit(ProviderSubmission submission) { + Objects.requireNonNull(submission, "submission"); + return CompletableFuture.supplyAsync(() -> send(submission), executor); + } + + private ProviderSubmissionResult send(ProviderSubmission submission) { + long startedNanos = System.nanoTime(); + ContactPointValue value = + protector.reveal( + submission.contactPoint(), + AccessContext.dispatch(submission.profile().profileId().value())); + if (!(value instanceof EmailAddress address)) { + throw new IllegalArgumentException("SMTP requires an email contact point"); + } + + try { + dispatch.send( + mimeFactory.create( + submission, address.normalized(), properties.senderIdentity(), List.of())); + return ProviderSubmissionResult.accepted(null, "250", elapsedSince(startedNanos)); + } catch (SmtpDispatchException failure) { + return classifier.classify(failure, elapsedSince(startedNanos)); + } + } + + private static Duration elapsedSince(long startedNanos) { + return Duration.ofNanos(System.nanoTime() - startedNanos); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpProviderProperties.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpProviderProperties.java new file mode 100644 index 00000000..5a499e7d --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpProviderProperties.java @@ -0,0 +1,57 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp; + +import java.time.Duration; +import java.util.Objects; + +/** + * SMTP profile. + * + *

Every timeout is required and finite. An unbounded SMTP read timeout is how one unresponsive + * relay turns into an exhausted dispatch pool. + */ +public record SmtpProviderProperties( + String host, + int port, + TlsMode tlsMode, + String senderIdentity, + Duration connectTimeout, + Duration readTimeout, + Duration writeTimeout, + int maxConcurrency) { + + /** Transport security of the SMTP session. */ + public enum TlsMode { + STARTTLS_REQUIRED, + IMPLICIT_TLS + } + + public SmtpProviderProperties { + Objects.requireNonNull(host, "host"); + Objects.requireNonNull(tlsMode, "tlsMode"); + Objects.requireNonNull(senderIdentity, "senderIdentity"); + requireFinite(connectTimeout, "connectTimeout"); + requireFinite(readTimeout, "readTimeout"); + requireFinite(writeTimeout, "writeTimeout"); + if (host.isBlank()) { + throw new IllegalArgumentException("host"); + } + if (port < 1 || port > 65535) { + throw new IllegalArgumentException("port"); + } + if (maxConcurrency < 1) { + throw new IllegalArgumentException("maxConcurrency"); + } + if (tlsMode == TlsMode.STARTTLS_REQUIRED && port == 25) { + // Port 25 with opportunistic STARTTLS is the classic silent-downgrade path; the profile has + // to say which it means. + throw new IllegalArgumentException("STARTTLS on port 25 must be declared explicitly"); + } + } + + private static void requireFinite(Duration timeout, String name) { + Objects.requireNonNull(timeout, name); + if (timeout.isNegative() || timeout.isZero()) { + throw new IllegalArgumentException(name + " must be positive and finite"); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAdapter.java new file mode 100644 index 00000000..5fde0cfa --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAdapter.java @@ -0,0 +1,89 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.callback.CallbackRequest; +import dev.caskeleton.application.notification.platform.callback.CallbackVerificationResult; +import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent; +import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapter; +import dev.caskeleton.application.notification.platform.callback.VerifiedCallback; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** Twilio status callback verification and normalization. */ +public final class TwilioCallbackAdapter implements ProviderCallbackAdapter { + + private static final ProviderId PROVIDER_ID = new ProviderId("twilio"); + + private final TwilioSignatureValidator validator; + private final TwilioStatusNormalizer normalizer; + private final TwilioProviderProperties properties; + private final SecretMaterialProvider secrets; + + public TwilioCallbackAdapter( + TwilioSignatureValidator validator, + TwilioStatusNormalizer normalizer, + TwilioProviderProperties properties, + SecretMaterialProvider secrets) { + this.validator = Objects.requireNonNull(validator, "validator"); + this.normalizer = Objects.requireNonNull(normalizer, "normalizer"); + this.properties = Objects.requireNonNull(properties, "properties"); + this.secrets = Objects.requireNonNull(secrets, "secrets"); + } + + @Override + public ProviderId providerId() { + return PROVIDER_ID; + } + + @Override + public CallbackVerificationResult verify(CallbackRequest request) { + Objects.requireNonNull(request, "request"); + Map parameters = parseForm(request.body()); + boolean valid = + validator.isValid( + properties.canonicalCallbackUrl(), + parameters, + request.header("x-twilio-signature").orElse(null), + secrets.activeKey(SecretPurpose.CALLBACK_SIGNING).material()); + return valid + ? CallbackVerificationResult.valid(new VerifiedCallback(request, parameters)) + : CallbackVerificationResult.invalid("TWILIO_SIGNATURE_MISMATCH"); + } + + @Override + public List normalize(VerifiedCallback callback) { + Objects.requireNonNull(callback, "callback"); + Optional occurredAt = Optional.of(callback.request().receivedAt()); + return List.of(normalizer.normalize(callback.canonicalParameters(), occurredAt)); + } + + private static Map parseForm(byte[] body) { + Map parameters = new LinkedHashMap<>(); + String raw = new String(body, StandardCharsets.UTF_8); + if (raw.isBlank()) { + return parameters; + } + for (String pair : java.util.regex.Pattern.compile("&").split(raw, -1)) { + if (pair.isEmpty()) { + continue; + } + int separator = pair.indexOf('='); + if (separator < 0) { + parameters.put(URLDecoder.decode(pair, StandardCharsets.UTF_8), ""); + } else { + parameters.put( + URLDecoder.decode(pair.substring(0, separator), StandardCharsets.UTF_8), + URLDecoder.decode(pair.substring(separator + 1), StandardCharsets.UTF_8)); + } + } + return parameters; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioDeliveryProjector.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioDeliveryProjector.java new file mode 100644 index 00000000..cb5edccc --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioDeliveryProjector.java @@ -0,0 +1,18 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.callback.StandardDeliveryProjector; + +/** + * Twilio projector. + * + *

No provider-specific transitions are needed: the shared table already ignores a {@code sent} + * that follows a {@code delivered}, which is the exact Twilio behaviour this projector has to + * survive. + */ +public final class TwilioDeliveryProjector extends StandardDeliveryProjector { + + public TwilioDeliveryProjector() { + super(new ProviderId("twilio")); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioFailureClassifier.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioFailureClassifier.java new file mode 100644 index 00000000..cb9314c3 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioFailureClassifier.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.provider.ProviderFailure; +import java.util.Optional; +import java.util.Set; + +/** Maps Twilio error codes onto the stable failure vocabulary. */ +public final class TwilioFailureClassifier { + + /** Twilio error codes that identify the destination number rather than the request. */ + private static final Set INVALID_NUMBER_CODES = + Set.of(21211, 21214, 21610, 21612, 21614); + + /** Classify a non-2xx Twilio response. */ + public ProviderFailure classify(NotificationHttpResponse response) { + Optional code = errorCode(response); + if (code.filter(INVALID_NUMBER_CODES::contains).isPresent()) { + return new ProviderFailure( + NotificationFailureCode.CONTACT_POINT_INVALID, + FailureCategory.INVALID_RECIPIENT, + false, + Optional.empty(), + code.map(String::valueOf)); + } + if (response.statusCode() == 429) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_THROTTLED, + FailureCategory.THROTTLED, + true, + ProviderResults.retryAfter(response.header("retry-after")), + code.map(String::valueOf)); + } + return ProviderResults.fromStatus( + response.statusCode(), ProviderResults.retryAfter(response.header("retry-after"))); + } + + private static Optional errorCode(NotificationHttpResponse response) { + try { + var node = NotificationJsonMapper.mapper().readTree(response.bodyAsString()); + var code = node.get("code"); + return code == null || code.isNull() ? Optional.empty() : Optional.of(code.asInt()); + } catch (RuntimeException unparseable) { + return Optional.empty(); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioProviderProperties.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioProviderProperties.java new file mode 100644 index 00000000..8903e6a3 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioProviderProperties.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import java.net.URI; +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; + +/** + * Twilio profile. + * + *

{@code canonicalCallbackUrl} is pinned here rather than reconstructed from the incoming + * request. Twilio signs the URL it called, and a reverse proxy that rewrites scheme or host makes a + * server-side reconstruction disagree with the signature — the most common cause of "valid webhook, + * failed verification". + */ +public record TwilioProviderProperties( + URI endpoint, + String accountSid, + Optional messagingServiceSid, + Optional fromNumber, + String canonicalCallbackUrl, + Duration timeout, + Duration maxReconciliationAge) { + + public TwilioProviderProperties { + Objects.requireNonNull(endpoint, "endpoint"); + Objects.requireNonNull(accountSid, "accountSid"); + Objects.requireNonNull(messagingServiceSid, "messagingServiceSid"); + Objects.requireNonNull(fromNumber, "fromNumber"); + Objects.requireNonNull(canonicalCallbackUrl, "canonicalCallbackUrl"); + Objects.requireNonNull(timeout, "timeout"); + Objects.requireNonNull(maxReconciliationAge, "maxReconciliationAge"); + if (accountSid.isBlank()) { + throw new IllegalArgumentException("accountSid"); + } + if (messagingServiceSid.isEmpty() == fromNumber.isEmpty()) { + throw new IllegalArgumentException( + "exactly one of messagingServiceSid or fromNumber must be configured"); + } + if (timeout.isNegative() || timeout.isZero()) { + throw new IllegalArgumentException("timeout must be positive and finite"); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioReconciliationCapability.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioReconciliationCapability.java new file mode 100644 index 00000000..692e8282 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioReconciliationCapability.java @@ -0,0 +1,138 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException; +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot; +import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot; +import dev.caskeleton.application.notification.platform.provider.ReconciliationCapability; +import dev.caskeleton.application.notification.platform.provider.ReconciliationResult; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.Base64; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +/** + * Twilio message status polling. + * + *

Callbacks go missing. Twilio itself recommends polling when a status has not moved, so an + * attempt whose callback never arrived is corrected here rather than left ambiguous forever. + * + *

Two bounds keep the correction from becoming a second incident: an attempt older than the + * configured maximum is abandoned rather than polled indefinitely, and the query runs through the + * same gateway — and therefore the same provider rate budget — as dispatch. + */ +public final class TwilioReconciliationCapability implements ReconciliationCapability { + + private final NotificationHttpGateway gateway; + private final TwilioProviderProperties properties; + private final TwilioStatusNormalizer normalizer; + private final SecretMaterialProvider secrets; + private final Clock clock; + + public TwilioReconciliationCapability( + NotificationHttpGateway gateway, + TwilioProviderProperties properties, + TwilioStatusNormalizer normalizer, + SecretMaterialProvider secrets, + Clock clock) { + this.gateway = Objects.requireNonNull(gateway, "gateway"); + this.properties = Objects.requireNonNull(properties, "properties"); + this.normalizer = Objects.requireNonNull(normalizer, "normalizer"); + this.secrets = Objects.requireNonNull(secrets, "secrets"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @Override + public boolean supports(ProviderProfileSnapshot profile) { + Objects.requireNonNull(profile, "profile"); + return profile.capabilities().statusQuery(); + } + + @Override + public CompletionStage reconcile(DeliveryAttemptSnapshot attempt) { + Objects.requireNonNull(attempt, "attempt"); + + Optional messageSid = attempt.providerRequestId(); + if (messageSid.isEmpty()) { + // Without a provider identifier there is nothing to ask about. This is the honest outcome of + // an ambiguous submission that never produced a SID, not a failure to try. + return CompletableFuture.completedFuture(new ReconciliationResult.Unsupported()); + } + if (isTooOld(attempt)) { + return CompletableFuture.completedFuture( + new ReconciliationResult.Failed("RECONCILIATION_WINDOW_EXPIRED", false)); + } + + try { + NotificationHttpResponse response = gateway.exchange(statusRequest(messageSid.get())); + if (!response.isSuccessful()) { + return CompletableFuture.completedFuture( + new ReconciliationResult.Failed( + "STATUS_QUERY_" + response.statusCode(), response.statusCode() >= 500)); + } + var node = NotificationJsonMapper.mapper().readTree(response.bodyAsString()); + String status = + Optional.ofNullable(node.get("status")).map(value -> value.asString()).orElse("unknown"); + + if (isPending(status)) { + return CompletableFuture.completedFuture( + new ReconciliationResult.StillUnknown(clock.instant().plusSeconds(300))); + } + return CompletableFuture.completedFuture( + new ReconciliationResult.Confirmed( + normalizer.normalize( + Map.of("MessageSid", messageSid.get(), "MessageStatus", status), + Optional.of(clock.instant())))); + } catch (NotificationHttpTransportException transportFailure) { + return CompletableFuture.completedFuture( + new ReconciliationResult.Failed("STATUS_QUERY_TRANSPORT", true)); + } + } + + private boolean isTooOld(DeliveryAttemptSnapshot attempt) { + return attempt.startedAt().plus(properties.maxReconciliationAge()).isBefore(clock.instant()); + } + + private static boolean isPending(String status) { + return switch (status) { + case "accepted", "queued", "sending", "scheduled" -> true; + default -> false; + }; + } + + private NotificationHttpRequest statusRequest(String messageSid) { + String credentials = + Base64.getEncoder() + .encodeToString( + (properties.accountSid() + + ":" + + new String( + secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material(), + StandardCharsets.UTF_8)) + .getBytes(StandardCharsets.UTF_8)); + + return new NotificationHttpRequest( + "GET", + URI.create( + properties.endpoint() + + "/2010-04-01/Accounts/" + + properties.accountSid() + + "/Messages/" + + messageSid + + ".json"), + JdkNotificationHttpGateway.headers(Map.of("authorization", "Basic " + credentials)), + new byte[0], + properties.timeout()); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioRequestMapper.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioRequestMapper.java new file mode 100644 index 00000000..70267d28 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioRequestMapper.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest; +import dev.caskeleton.application.notification.platform.api.content.SmsContent; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** Builds the Twilio {@code Messages.json} form request. */ +public final class TwilioRequestMapper { + + private final TwilioProviderProperties properties; + + public TwilioRequestMapper(TwilioProviderProperties properties) { + this.properties = Objects.requireNonNull(properties, "properties"); + } + + /** Map one submission into a form-encoded request. */ + public NotificationHttpRequest map( + ProviderSubmission submission, String recipientE164, byte[] authToken) { + Objects.requireNonNull(submission, "submission"); + if (!(submission.content().content() instanceof SmsContent sms)) { + throw new IllegalArgumentException("Twilio requires SMS content"); + } + + Map form = new LinkedHashMap<>(); + form.put("To", recipientE164); + properties.messagingServiceSid().ifPresent(sid -> form.put("MessagingServiceSid", sid)); + properties.fromNumber().ifPresent(from -> form.put("From", from)); + form.put("Body", sms.text()); + form.put("StatusCallback", properties.canonicalCallbackUrl()); + + byte[] body = encode(form).getBytes(StandardCharsets.UTF_8); + String credentials = + Base64.getEncoder() + .encodeToString( + (properties.accountSid() + ":" + new String(authToken, StandardCharsets.UTF_8)) + .getBytes(StandardCharsets.UTF_8)); + + return new NotificationHttpRequest( + "POST", + URI.create( + properties.endpoint() + + "/2010-04-01/Accounts/" + + properties.accountSid() + + "/Messages.json"), + JdkNotificationHttpGateway.headers( + Map.of( + "content-type", + "application/x-www-form-urlencoded", + "authorization", + "Basic " + credentials)), + body, + properties.timeout()); + } + + private static String encode(Map form) { + return form.entrySet().stream() + .map( + entry -> + URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8) + + "=" + + URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)) + .collect(Collectors.joining("&")); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSignatureValidator.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSignatureValidator.java new file mode 100644 index 00000000..e6d44946 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSignatureValidator.java @@ -0,0 +1,49 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.Map; +import java.util.TreeMap; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * {@code X-Twilio-Signature} validation. + * + *

The signature covers the full external URL followed by every POST parameter in sorted key + * order, concatenated as {@code key + value}. The URL is the one Twilio called, which is why the + * profile pins it rather than the adapter rebuilding it from proxy headers. + */ +public final class TwilioSignatureValidator { + + /** Whether the presented signature matches. */ + public boolean isValid( + String canonicalUrl, + Map parameters, + String presentedSignature, + byte[] authToken) { + if (presentedSignature == null || presentedSignature.isBlank()) { + return false; + } + StringBuilder payload = new StringBuilder(canonicalUrl); + new TreeMap<>(parameters) + .forEach((key, value) -> payload.append(key).append(value == null ? "" : value)); + + try { + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(new SecretKeySpec(authToken, "HmacSHA1")); + String expected = + Base64.getEncoder() + .encodeToString(mac.doFinal(payload.toString().getBytes(StandardCharsets.UTF_8))); + // Constant-time comparison: a timing oracle on a webhook signature is a slow but real forgery + // path. + return MessageDigest.isEqual( + expected.getBytes(StandardCharsets.UTF_8), + presentedSignature.getBytes(StandardCharsets.UTF_8)); + } catch (GeneralSecurityException failure) { + return false; + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapter.java new file mode 100644 index 00000000..ad1cebda --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapter.java @@ -0,0 +1,139 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException; +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.contact.ContactPointValue; +import dev.caskeleton.application.notification.platform.contact.PhoneNumber; +import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; +import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; +import dev.caskeleton.application.notification.platform.security.AccessContext; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +/** + * Twilio Programmable Messaging submission. + * + *

{@code accepted}, {@code queued} and {@code sending} are all acceptance and nothing more. + * Twilio itself models {@code sent} and {@code delivered} as later, separate events, so this + * adapter never returns a delivery outcome — those arrive through status callbacks and + * reconciliation. + */ +public final class TwilioSmsProviderAdapter implements NotificationProviderAdapter { + + private static final ProviderId PROVIDER_ID = new ProviderId("twilio"); + + private final NotificationHttpGateway gateway; + private final TwilioRequestMapper mapper; + private final TwilioFailureClassifier classifier; + private final ContactPointProtector protector; + private final SecretMaterialProvider secrets; + + public TwilioSmsProviderAdapter( + NotificationHttpGateway gateway, + TwilioRequestMapper mapper, + TwilioFailureClassifier classifier, + ContactPointProtector protector, + SecretMaterialProvider secrets) { + this.gateway = Objects.requireNonNull(gateway, "gateway"); + this.mapper = Objects.requireNonNull(mapper, "mapper"); + this.classifier = Objects.requireNonNull(classifier, "classifier"); + this.protector = Objects.requireNonNull(protector, "protector"); + this.secrets = Objects.requireNonNull(secrets, "secrets"); + } + + @Override + public ProviderId providerId() { + return PROVIDER_ID; + } + + @Override + public Set channels() { + return Set.of(Channel.SMS); + } + + @Override + public ProviderCapabilities capabilities() { + // statusCallback and statusQuery are both true: Twilio delivers status callbacks and also lets + // the platform poll, which is what makes missing-callback reconciliation possible. + return new ProviderCapabilities( + false, false, true, true, true, false, false, false, 1, 1_600L, Duration.ofHours(4)); + } + + @Override + public CompletionStage submit(ProviderSubmission submission) { + Objects.requireNonNull(submission, "submission"); + return CompletableFuture.completedFuture(send(submission)); + } + + private ProviderSubmissionResult send(ProviderSubmission submission) { + long startedNanos = System.nanoTime(); + ContactPointValue value = + protector.reveal( + submission.contactPoint(), + AccessContext.dispatch(submission.profile().profileId().value())); + if (!(value instanceof PhoneNumber phone)) { + throw new IllegalArgumentException("Twilio requires a phone contact point"); + } + + var request = + mapper.map( + submission, + phone.e164(), + secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material()); + + try { + NotificationHttpResponse response = gateway.exchange(request); + Duration elapsed = elapsedSince(startedNanos); + if (!response.isSuccessful()) { + return ProviderSubmissionResult.rejected(classifier.classify(response), elapsed); + } + var parsed = parse(response); + return switch (parsed.status()) { + case "accepted", "queued", "sending", "scheduled" -> + ProviderSubmissionResult.accepted(parsed.sid(), parsed.status(), elapsed); + case "failed", "undelivered" -> + ProviderSubmissionResult.rejected(classifier.classify(response), elapsed); + default -> + // An unrecognised status is preserved verbatim rather than guessed at; the native value + // reaches the ledger and the stable vocabulary stays closed. + ProviderSubmissionResult.accepted(parsed.sid(), parsed.status(), elapsed); + }; + } catch (NotificationHttpTransportException transportFailure) { + return ProviderResults.fromTransport(transportFailure, elapsedSince(startedNanos)); + } + } + + private static TwilioMessage parse(NotificationHttpResponse response) { + try { + var node = NotificationJsonMapper.mapper().readTree(response.bodyAsString()); + return new TwilioMessage( + Optional.ofNullable(node.get("sid")).map(value -> value.asString()).orElse(null), + Optional.ofNullable(node.get("status")) + .map(value -> value.asString()) + .orElse("accepted")); + } catch (RuntimeException unparseable) { + return new TwilioMessage(null, "accepted"); + } + } + + private static Duration elapsedSince(long startedNanos) { + return Duration.ofNanos(System.nanoTime() - startedNanos); + } + + /** Minimal projection of the Twilio message resource. */ + private record TwilioMessage(String sid, String status) {} +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioStatusNormalizer.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioStatusNormalizer.java new file mode 100644 index 00000000..4c816e7f --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioStatusNormalizer.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import dev.caskeleton.application.notification.platform.callback.NormalizedEventType; +import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; + +/** + * Twilio status to stable event. + * + *

The mapping is by status name, never by arrival time. Twilio does not guarantee callback + * ordering, so a {@code sent} that arrives after {@code delivered} has to be recognisable as the + * weaker fact it is. + */ +public final class TwilioStatusNormalizer { + + /** Normalize one status callback. */ + public NormalizedProviderEvent normalize( + Map parameters, Optional occurredAt) { + String status = parameters.getOrDefault("MessageStatus", "unknown"); + Optional sid = Optional.ofNullable(parameters.get("MessageSid")); + + NormalizedEventType type = + switch (status) { + case "accepted", "queued", "scheduled", "sending" -> + NormalizedEventType.PROVIDER_ACCEPTED; + case "sent" -> NormalizedEventType.SENT; + case "delivered" -> NormalizedEventType.DELIVERY_CONFIRMED; + case "undelivered" -> NormalizedEventType.UNDELIVERED; + case "failed" -> NormalizedEventType.PROVIDER_REJECTED; + default -> NormalizedEventType.UNKNOWN; + }; + + Map attributes = + parameters.containsKey("ErrorCode") + ? Map.of("errorCode", parameters.get("ErrorCode")) + : Map.of(); + return new NormalizedProviderEvent(type, status, Optional.empty(), sid, occurredAt, attributes); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapter.java new file mode 100644 index 00000000..93486df6 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapter.java @@ -0,0 +1,188 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webhook; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException; +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; +import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities; +import dev.caskeleton.application.notification.platform.provider.ProviderFailure; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.Function; + +/** + * Webhook extension. + * + *

Two gateways, chosen by whether the destination is operator configured or user supplied. A + * dynamic target never receives an {@code Authorization} or {@code Cookie} header, because a + * webhook pointed at an attacker's host would otherwise hand over whatever credential the trusted + * path uses. + * + *

An accepted body with no response is ambiguous here for the same reason as everywhere else: + * the receiver may already have acted on it. + */ +public final class WebhookNotificationProviderAdapter implements NotificationProviderAdapter { + + private static final ProviderId PROVIDER_ID = new ProviderId("webhook"); + private static final int MAX_DIAGNOSTIC_BODY = 512; + + private final NotificationHttpGateway trustedGateway; + private final NotificationHttpGateway dynamicGateway; + private final WebhookSignatureStrategy signatures; + private final SecretMaterialProvider secrets; + private final Function subscriptionResolver; + private final Duration timeout; + private final Clock clock; + + public WebhookNotificationProviderAdapter( + NotificationHttpGateway trustedGateway, + NotificationHttpGateway dynamicGateway, + WebhookSignatureStrategy signatures, + SecretMaterialProvider secrets, + Function subscriptionResolver, + Duration timeout, + Clock clock) { + this.trustedGateway = Objects.requireNonNull(trustedGateway, "trustedGateway"); + this.dynamicGateway = Objects.requireNonNull(dynamicGateway, "dynamicGateway"); + this.signatures = Objects.requireNonNull(signatures, "signatures"); + this.secrets = Objects.requireNonNull(secrets, "secrets"); + this.subscriptionResolver = + Objects.requireNonNull(subscriptionResolver, "subscriptionResolver"); + this.timeout = Objects.requireNonNull(timeout, "timeout"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @Override + public ProviderId providerId() { + return PROVIDER_ID; + } + + @Override + public Set channels() { + return Set.of(Channel.WEBHOOK); + } + + @Override + public ProviderCapabilities capabilities() { + return new ProviderCapabilities( + false, false, false, false, false, false, false, false, 1, 1_000_000L, Duration.ofHours(1)); + } + + @Override + public CompletionStage submit(ProviderSubmission submission) { + Objects.requireNonNull(submission, "submission"); + return CompletableFuture.completedFuture(send(submission)); + } + + private ProviderSubmissionResult send(ProviderSubmission submission) { + long startedNanos = System.nanoTime(); + WebhookSubscription subscription = subscriptionResolver.apply(submission); + + byte[] body = + NotificationJsonMapper.mapper() + .writeValueAsString( + Map.of( + "attemptId", submission.attemptId().value().toString(), + "contentDigest", submission.content().contentDigest())) + .getBytes(StandardCharsets.UTF_8); + + Map headers = new LinkedHashMap<>(); + headers.put("content-type", "application/json"); + if (subscription.trusted() && subscription.signingKeyRef().isPresent()) { + var timestamp = clock.instant(); + headers.put( + WebhookSignatureStrategy.TIMESTAMP_HEADER, Long.toString(timestamp.getEpochSecond())); + headers.put( + WebhookSignatureStrategy.SIGNATURE_HEADER, + signatures.sign( + body, timestamp, secrets.activeKey(SecretPurpose.CALLBACK_SIGNING).material())); + } + + NotificationHttpRequest request = + new NotificationHttpRequest( + "POST", + subscription.target(), + JdkNotificationHttpGateway.headers(headers), + body, + timeout); + + NotificationHttpGateway gateway = subscription.trusted() ? trustedGateway : dynamicGateway; + try { + NotificationHttpResponse response = gateway.exchange(request); + Duration elapsed = Duration.ofNanos(System.nanoTime() - startedNanos); + if (response.isSuccessful()) { + return ProviderSubmissionResult.accepted( + null, Integer.toString(response.statusCode()), elapsed); + } + // 429 is the receiver saying "later", not "no". Classifying it as permanent would drop a + // notification a working receiver explicitly asked us to resend, and no later evidence can + // tell that apart from a genuine rejection. + boolean throttled = response.statusCode() == 429; + boolean transientFailure = throttled || response.statusCode() >= 500; + return ProviderSubmissionResult.rejected( + new ProviderFailure( + NotificationFailureCode.PROVIDER_REJECTED, + throttled + ? FailureCategory.THROTTLED + : transientFailure + ? FailureCategory.TRANSIENT_PROVIDER + : FailureCategory.PERMANENT_PROVIDER, + transientFailure, + retryAfter(response), + Optional.of(boundedDiagnostic(response))), + elapsed); + } catch (NotificationHttpTransportException transportFailure) { + return ProviderResults.fromTransport( + transportFailure, Duration.ofNanos(System.nanoTime() - startedNanos)); + } + } + + /** + * The receiver's own backoff hint, when it sent a usable one. + * + *

Only the delta-seconds form is honoured. RFC 9110 also allows an HTTP-date, but a receiver + * whose clock disagrees with ours would then dictate a wait computed from the difference — which + * is how one misconfigured subscriber stalls a queue. + */ + private static Optional retryAfter(NotificationHttpResponse response) { + return response + .header("retry-after") + .flatMap( + value -> { + try { + long seconds = Long.parseLong(value.trim()); + return seconds > 0 ? Optional.of(Duration.ofSeconds(seconds)) : Optional.empty(); + } catch (NumberFormatException notDeltaSeconds) { + return Optional.empty(); + } + }); + } + + /** Only a bounded slice of the response is kept; a receiver's body is not our log. */ + private static String boundedDiagnostic(NotificationHttpResponse response) { + String body = response.bodyAsString(); + return response.statusCode() + + ":" + + body.substring(0, Math.min(body.length(), MAX_DIAGNOSTIC_BODY)); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookSignatureStrategy.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookSignatureStrategy.java new file mode 100644 index 00000000..80e29c40 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookSignatureStrategy.java @@ -0,0 +1,40 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webhook; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.time.Instant; +import java.util.HexFormat; +import java.util.Objects; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Request signing for trusted webhook subscriptions. + * + *

The timestamp is inside the signed payload, so a captured request cannot be replayed later + * without the receiver noticing the skew. + */ +public final class WebhookSignatureStrategy { + + /** Header carrying the signature. */ + public static final String SIGNATURE_HEADER = "x-notification-signature"; + + /** Header carrying the signed timestamp. */ + public static final String TIMESTAMP_HEADER = "x-notification-timestamp"; + + /** Compute the signature over timestamp and body. */ + public String sign(byte[] body, Instant timestamp, byte[] signingKey) { + Objects.requireNonNull(body, "body"); + Objects.requireNonNull(timestamp, "timestamp"); + Objects.requireNonNull(signingKey, "signingKey"); + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(signingKey, "HmacSHA256")); + mac.update(Long.toString(timestamp.getEpochSecond()).getBytes(StandardCharsets.US_ASCII)); + mac.update((byte) '.'); + return "v1=" + HexFormat.of().formatHex(mac.doFinal(body)); + } catch (GeneralSecurityException failure) { + throw new IllegalStateException("webhook signing failed", failure); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookSubscription.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookSubscription.java new file mode 100644 index 00000000..ea4911c4 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookSubscription.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webhook; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationEndpoints; +import java.net.URI; +import java.util.Objects; +import java.util.Optional; + +/** + * A webhook destination. + * + *

{@code trusted} decides which gateway carries the call. A trusted subscription is operator + * configured and may use platform credentials; a dynamic one comes from user input and must not + * inherit anything, because that is how a webhook feature becomes an SSRF credential-relay. + */ +public record WebhookSubscription( + String subscriptionId, URI target, boolean trusted, Optional signingKeyRef) { + + public WebhookSubscription { + Objects.requireNonNull(subscriptionId, "subscriptionId"); + Objects.requireNonNull(target, "target"); + Objects.requireNonNull(signingKeyRef, "signingKeyRef"); + if (subscriptionId.isBlank()) { + throw new IllegalArgumentException("subscriptionId"); + } + NotificationEndpoints.requireSecureOrLoopback(target, "webhook target"); + if (!trusted && signingKeyRef.isPresent()) { + throw new IllegalArgumentException( + "a dynamic target may not be paired with a platform signing key"); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/EncryptedWebPushPayload.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/EncryptedWebPushPayload.java new file mode 100644 index 00000000..359456fc --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/EncryptedWebPushPayload.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import java.util.Arrays; +import java.util.Objects; + +/** An RFC 8291 {@code aes128gcm} record ready to be sent as the request body. */ +@SuppressWarnings("ArrayRecordComponent") // defensive copies on construction and on every accessor +public record EncryptedWebPushPayload(byte[] body, String contentEncoding) { + + public EncryptedWebPushPayload { + Objects.requireNonNull(body, "body"); + Objects.requireNonNull(contentEncoding, "contentEncoding"); + body = body.clone(); + } + + @Override + public byte[] body() { + return body.clone(); + } + + @Override + public boolean equals(Object other) { + return other instanceof EncryptedWebPushPayload payload + && Arrays.equals(body, payload.body) + && contentEncoding.equals(payload.contentEncoding); + } + + @Override + public int hashCode() { + return Objects.hash(Arrays.hashCode(body), contentEncoding); + } + + @Override + public String toString() { + return "EncryptedWebPushPayload[encoding=" + contentEncoding + ", bytes=" + body.length + "]"; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/Rfc8291Aes128GcmEncryptor.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/Rfc8291Aes128GcmEncryptor.java new file mode 100644 index 00000000..14777489 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/Rfc8291Aes128GcmEncryptor.java @@ -0,0 +1,193 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue; +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.AlgorithmParameters; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.security.spec.ECParameterSpec; +import java.security.spec.ECPoint; +import java.security.spec.ECPublicKeySpec; +import java.util.Objects; +import javax.crypto.Cipher; +import javax.crypto.KeyAgreement; +import javax.crypto.Mac; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * RFC 8291 Web Push payload encryption. + * + *

The subscription's public key and auth secret are the only inputs the application server has, + * and the sequence — ECDH, then HKDF keyed by the auth secret, then HKDF keyed by a fresh salt — is + * what binds the ciphertext to that one subscriber. A fresh ephemeral key pair per message is not + * an optimisation choice: reusing one would let two messages to the same subscriber share a key + * stream. + */ +public final class Rfc8291Aes128GcmEncryptor { + + private static final int SALT_BYTES = 16; + private static final int KEY_BYTES = 16; + private static final int NONCE_BYTES = 12; + private static final int RECORD_SIZE = 4096; + private static final int TAG_BITS = 128; + private static final byte PADDING_DELIMITER = 0x02; + + private final SecureRandom random; + + public Rfc8291Aes128GcmEncryptor() { + this(new SecureRandom()); + } + + public Rfc8291Aes128GcmEncryptor(SecureRandom random) { + this.random = Objects.requireNonNull(random, "random"); + } + + /** Encrypt one payload for one subscription. */ + public EncryptedWebPushPayload encrypt(WebPushSubscriptionValue subscription, byte[] plaintext) { + Objects.requireNonNull(subscription, "subscription"); + Objects.requireNonNull(plaintext, "plaintext"); + if (plaintext.length + 1 > RECORD_SIZE - 16 - 5 - 65 - 16) { + throw new IllegalArgumentException("payload exceeds the Web Push record size"); + } + + try { + byte[] userAgentPublic = subscription.p256dh(); + byte[] authSecret = subscription.authSecret(); + + KeyPair ephemeral = generateP256KeyPair(); + byte[] applicationServerPublic = encodePoint((ECPublicKey) ephemeral.getPublic()); + + byte[] sharedSecret = agree(ephemeral, decodePoint(userAgentPublic)); + byte[] ikm = + hkdf( + authSecret, + sharedSecret, + concat( + "WebPush: info\0".getBytes(StandardCharsets.US_ASCII), + userAgentPublic, + applicationServerPublic), + 32); + + byte[] salt = new byte[SALT_BYTES]; + random.nextBytes(salt); + byte[] contentEncryptionKey = + hkdf( + salt, + ikm, + "Content-Encoding: aes128gcm\0".getBytes(StandardCharsets.US_ASCII), + KEY_BYTES); + byte[] nonce = + hkdf( + salt, + ikm, + "Content-Encoding: nonce\0".getBytes(StandardCharsets.US_ASCII), + NONCE_BYTES); + + byte[] padded = concat(plaintext, new byte[] {PADDING_DELIMITER}); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init( + Cipher.ENCRYPT_MODE, + new SecretKeySpec(contentEncryptionKey, "AES"), + new GCMParameterSpec(TAG_BITS, nonce)); + byte[] ciphertext = cipher.doFinal(padded); + + ByteArrayOutputStream body = new ByteArrayOutputStream(); + body.writeBytes(salt); + body.writeBytes(ByteBuffer.allocate(4).putInt(RECORD_SIZE).array()); + body.write(applicationServerPublic.length); + body.writeBytes(applicationServerPublic); + body.writeBytes(ciphertext); + return new EncryptedWebPushPayload(body.toByteArray(), "aes128gcm"); + } catch (GeneralSecurityException failure) { + throw new IllegalStateException("Web Push payload encryption failed", failure); + } + } + + /** HKDF with SHA-256, as used throughout RFC 8291. */ + public static byte[] hkdf(byte[] salt, byte[] ikm, byte[] info, int length) { + try { + Mac extract = Mac.getInstance("HmacSHA256"); + extract.init(new SecretKeySpec(salt, "HmacSHA256")); + byte[] prk = extract.doFinal(ikm); + + Mac expand = Mac.getInstance("HmacSHA256"); + expand.init(new SecretKeySpec(prk, "HmacSHA256")); + expand.update(info); + expand.update((byte) 1); + byte[] okm = expand.doFinal(); + return java.util.Arrays.copyOf(okm, length); + } catch (GeneralSecurityException failure) { + throw new IllegalStateException("HKDF failed", failure); + } + } + + private static KeyPair generateP256KeyPair() throws GeneralSecurityException { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + return generator.generateKeyPair(); + } + + private static byte[] agree(KeyPair ephemeral, PublicKey peer) throws GeneralSecurityException { + KeyAgreement agreement = KeyAgreement.getInstance("ECDH"); + agreement.init(ephemeral.getPrivate()); + agreement.doPhase(peer, true); + return agreement.generateSecret(); + } + + /** Uncompressed SEC1 encoding of a P-256 public key. */ + public static byte[] encodePoint(ECPublicKey key) { + byte[] x = unsigned(key.getW().getAffineX(), 32); + byte[] y = unsigned(key.getW().getAffineY(), 32); + byte[] encoded = new byte[65]; + encoded[0] = 0x04; + System.arraycopy(x, 0, encoded, 1, 32); + System.arraycopy(y, 0, encoded, 33, 32); + return encoded; + } + + /** Decode an uncompressed SEC1 P-256 point. */ + public static PublicKey decodePoint(byte[] encoded) throws GeneralSecurityException { + if (encoded.length != 65 || encoded[0] != 0x04) { + throw new GeneralSecurityException("expected an uncompressed P-256 point"); + } + BigInteger x = new BigInteger(1, java.util.Arrays.copyOfRange(encoded, 1, 33)); + BigInteger y = new BigInteger(1, java.util.Arrays.copyOfRange(encoded, 33, 65)); + AlgorithmParameters parameters = AlgorithmParameters.getInstance("EC"); + parameters.init(new ECGenParameterSpec("secp256r1")); + ECParameterSpec spec = parameters.getParameterSpec(ECParameterSpec.class); + return KeyFactory.getInstance("EC") + .generatePublic(new ECPublicKeySpec(new ECPoint(x, y), spec)); + } + + private static byte[] unsigned(BigInteger value, int length) { + byte[] raw = value.toByteArray(); + if (raw.length == length) { + return raw; + } + byte[] fixed = new byte[length]; + if (raw.length > length) { + System.arraycopy(raw, raw.length - length, fixed, 0, length); + } else { + System.arraycopy(raw, 0, fixed, length - raw.length, raw.length); + } + return fixed; + } + + private static byte[] concat(byte[]... parts) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (byte[] part : parts) { + out.writeBytes(part); + } + return out.toByteArray(); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/VapidAuthorizationProvider.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/VapidAuthorizationProvider.java new file mode 100644 index 00000000..87ef2155 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/VapidAuthorizationProvider.java @@ -0,0 +1,17 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial; +import java.net.URI; + +/** + * Supplies the {@code Authorization} header for a Web Push request. + * + *

An interface rather than the signer itself because VAPID is one identification scheme among + * several a push service may accept, and because the transport behaviour — TTL, urgency, status + * mapping — has to be testable without a real EC private key. + */ +public interface VapidAuthorizationProvider { + + /** Header value for one endpoint. */ + String authorization(URI endpoint, SecretKeyMaterial signingKey, String publicKeyBase64Url); +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/VapidJwtSigner.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/VapidJwtSigner.java new file mode 100644 index 00000000..7ba2272b --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/VapidJwtSigner.java @@ -0,0 +1,122 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Clock; +import java.time.Duration; +import java.util.Base64; +import java.util.Objects; + +/** + * RFC 8292 VAPID JWT. + * + *

The audience is the origin of the endpoint the request is going to, not a configured constant. + * A token minted for one push service and replayed at another is exactly what audience binding + * prevents. + * + *

The signature is converted from the JVM's DER encoding to the 64-byte JOSE form, because ES256 + * in JWT is fixed-width {@code r || s}. + */ +public final class VapidJwtSigner implements VapidAuthorizationProvider { + + private static final Duration MAX_LIFETIME = Duration.ofHours(12); + + private final Clock clock; + private final String subject; + + public VapidJwtSigner(Clock clock, String subject) { + this.clock = Objects.requireNonNull(clock, "clock"); + this.subject = Objects.requireNonNull(subject, "subject"); + if (!subject.startsWith("mailto:") && !subject.startsWith("https://")) { + throw new IllegalArgumentException("VAPID subject must be a mailto: or https: URI"); + } + } + + /** Sign a token for one endpoint. */ + public String sign(URI endpoint, SecretKeyMaterial signingKey, Duration lifetime) { + Objects.requireNonNull(endpoint, "endpoint"); + Objects.requireNonNull(signingKey, "signingKey"); + Objects.requireNonNull(lifetime, "lifetime"); + if (lifetime.compareTo(MAX_LIFETIME) > 0) { + throw new IllegalArgumentException("VAPID token lifetime must not exceed 12 hours"); + } + + String audience = endpoint.getScheme() + "://" + endpoint.getHost(); + String header = base64Url("{\"typ\":\"JWT\",\"alg\":\"ES256\"}"); + String payload = + base64Url( + "{\"aud\":\"" + + audience + + "\",\"exp\":" + + clock.instant().plus(lifetime).getEpochSecond() + + ",\"sub\":\"" + + subject + + "\"}"); + String signingInput = header + "." + payload; + + try { + PrivateKey privateKey = + KeyFactory.getInstance("EC") + .generatePrivate(new PKCS8EncodedKeySpec(signingKey.material())); + Signature signature = Signature.getInstance("SHA256withECDSA"); + signature.initSign(privateKey); + signature.update(signingInput.getBytes(StandardCharsets.US_ASCII)); + byte[] jose = derToJose(signature.sign()); + return signingInput + "." + Base64.getUrlEncoder().withoutPadding().encodeToString(jose); + } catch (GeneralSecurityException failure) { + throw new IllegalStateException("VAPID signing failed", failure); + } + } + + /** Full {@code Authorization} header value for a request. */ + @Override + public String authorization( + URI endpoint, SecretKeyMaterial signingKey, String publicKeyBase64Url) { + return "vapid t=" + + sign(endpoint, signingKey, Duration.ofHours(1)) + + ", k=" + + publicKeyBase64Url; + } + + private static String base64Url(String json) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } + + /** DER {@code SEQUENCE{INTEGER r, INTEGER s}} to fixed-width {@code r || s}. */ + private static byte[] derToJose(byte[] der) throws GeneralSecurityException { + if (der.length < 8 || der[0] != 0x30) { + throw new GeneralSecurityException("unexpected ECDSA signature encoding"); + } + int offset = der[1] == (byte) 0x81 ? 3 : 2; + if (der[offset] != 0x02) { + throw new GeneralSecurityException("unexpected ECDSA signature encoding"); + } + int rLength = der[offset + 1]; + int rStart = offset + 2; + int sLengthOffset = rStart + rLength; + if (der[sLengthOffset] != 0x02) { + throw new GeneralSecurityException("unexpected ECDSA signature encoding"); + } + int sLength = der[sLengthOffset + 1]; + int sStart = sLengthOffset + 2; + + byte[] jose = new byte[64]; + copyFixed(der, rStart, rLength, jose, 0); + copyFixed(der, sStart, sLength, jose, 32); + return jose; + } + + private static void copyFixed(byte[] source, int start, int length, byte[] target, int offset) { + int copyLength = Math.min(length, 32); + int sourceStart = start + length - copyLength; + System.arraycopy(source, sourceStart, target, offset + 32 - copyLength, copyLength); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/VapidKeyRegistry.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/VapidKeyRegistry.java new file mode 100644 index 00000000..3bfc6694 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/VapidKeyRegistry.java @@ -0,0 +1,88 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue; +import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.util.Map; +import java.util.Objects; + +/** + * The application server keys a push service will accept, keyed by VAPID key id. + * + *

Under RFC 8292 a subscription is created against one application server key. The user agent + * remembers it, and a push signed by a different key is rejected — so a VAPID rotation is not a + * server-side credential swap, it is a client migration that only completes when every subscriber's + * user agent re-subscribes. + * + *

That is why this registry keeps every key that still has live subscriptions rather + * than only the current one, and why {@link #requiresSubscriptionMigration} exists: the state has + * to be visible to operators, because the only way out of it is to prompt users to re-subscribe. + * Silently signing with the new key would look like a successful rotation and deliver nothing. + */ +public final class VapidKeyRegistry { + + private final SecretMaterialProvider secrets; + private final String activeKeyId; + private final Map publicKeysByKeyId; + + /** + * @param secrets source of the EC private keys; never a config property or a file + * @param activeKeyId key id new subscriptions are created against + * @param publicKeysByKeyId base64url-encoded uncompressed P-256 public keys, per key id + */ + public VapidKeyRegistry( + SecretMaterialProvider secrets, String activeKeyId, Map publicKeysByKeyId) { + this.secrets = Objects.requireNonNull(secrets, "secrets"); + this.activeKeyId = Objects.requireNonNull(activeKeyId, "activeKeyId"); + this.publicKeysByKeyId = Map.copyOf(Objects.requireNonNull(publicKeysByKeyId, "publicKeys")); + if (!this.publicKeysByKeyId.containsKey(activeKeyId)) { + throw new IllegalArgumentException("no public key registered for the active VAPID key id"); + } + } + + /** Key id new subscriptions should be created against. */ + public String activeKeyId() { + return activeKeyId; + } + + /** Base64url public key advertised to the user agent for a key id. */ + public String publicKey(String keyId) { + String publicKey = publicKeysByKeyId.get(Objects.requireNonNull(keyId, "keyId")); + if (publicKey == null) { + throw new IllegalStateException("unknown VAPID key id"); + } + return publicKey; + } + + /** + * Private key for the id a subscription was created with — never the active key. + * + *

Falling back to the active key here is the tempting shortcut and the wrong one: the push + * service would reject the token, and the failure would look like an invalid subscription rather + * than a misconfigured rotation. + */ + public SecretKeyMaterial signingKeyFor(WebPushSubscriptionValue subscription) { + Objects.requireNonNull(subscription, "subscription"); + SecretKeyMaterial key = secrets.keyById(subscription.vapidKeyId()); + if (key.purpose() != SecretPurpose.VAPID_SIGNING) { + throw new IllegalStateException("key is not a VAPID signing key"); + } + return key; + } + + /** Public key belonging to the subscription's own key id. */ + public String publicKeyFor(WebPushSubscriptionValue subscription) { + return publicKey(Objects.requireNonNull(subscription, "subscription").vapidKeyId()); + } + + /** + * True when this subscription is still bound to a superseded key. + * + *

Sends keep working — they are signed with the old key, which is why it is still registered — + * but the subscription cannot be considered migrated until the user agent re-subscribes. + */ + public boolean requiresSubscriptionMigration(WebPushSubscriptionValue subscription) { + return !activeKeyId.equals(Objects.requireNonNull(subscription, "subscription").vapidKeyId()); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushFailureClassifier.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushFailureClassifier.java new file mode 100644 index 00000000..eb580fee --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushFailureClassifier.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.provider.ProviderFailure; +import java.util.Optional; + +/** + * Web Push status classification. + * + *

RFC 8030 defines {@code 404} for an expired subscription. Several push services return {@code + * 410} instead, so both are treated as invalidation — hard-coding only the one a particular browser + * happens to send is how subscriptions accumulate forever. + */ +public final class WebPushFailureClassifier { + + /** Classify a non-2xx push-service response. */ + public ProviderFailure classify(NotificationHttpResponse response) { + int status = response.statusCode(); + if (status == 404 || status == 410) { + return new ProviderFailure( + NotificationFailureCode.CONTACT_POINT_INVALID, + FailureCategory.INVALID_RECIPIENT, + false, + Optional.empty(), + Optional.of(Integer.toString(status))); + } + if (status == 413) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT, + FailureCategory.INVALID_PAYLOAD, + false, + Optional.empty(), + Optional.of("413")); + } + return ProviderResults.fromStatus( + status, ProviderResults.retryAfter(response.header("retry-after"))); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushNotificationProviderAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushNotificationProviderAdapter.java new file mode 100644 index 00000000..41675cc7 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushNotificationProviderAdapter.java @@ -0,0 +1,111 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException; +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.contact.ContactPointValue; +import dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue; +import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; +import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; +import dev.caskeleton.application.notification.platform.security.AccessContext; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import java.time.Duration; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +/** + * Web Push transport. + * + *

A {@code 201} is push-service acceptance. RFC 8030 keeps user-agent acknowledgement in a + * separate receipt mechanism, so this adapter only reports {@code DEVICE_DELIVERED} where the + * profile declares that the service actually offers receipts. + */ +public final class WebPushNotificationProviderAdapter implements NotificationProviderAdapter { + + private static final ProviderId PROVIDER_ID = new ProviderId("webpush"); + + private final NotificationHttpGateway gateway; + private final WebPushRequestMapper mapper; + private final WebPushFailureClassifier classifier; + private final ContactPointProtector protector; + private final WebPushProviderProperties properties; + + public WebPushNotificationProviderAdapter( + NotificationHttpGateway gateway, + WebPushRequestMapper mapper, + WebPushFailureClassifier classifier, + ContactPointProtector protector, + WebPushProviderProperties properties) { + this.gateway = Objects.requireNonNull(gateway, "gateway"); + this.mapper = Objects.requireNonNull(mapper, "mapper"); + this.classifier = Objects.requireNonNull(classifier, "classifier"); + this.protector = Objects.requireNonNull(protector, "protector"); + this.properties = Objects.requireNonNull(properties, "properties"); + } + + @Override + public ProviderId providerId() { + return PROVIDER_ID; + } + + @Override + public Set channels() { + return Set.of(Channel.WEB_PUSH); + } + + @Override + public ProviderCapabilities capabilities() { + return new ProviderCapabilities( + false, + false, + properties.receiptsSupported(), + false, + properties.receiptsSupported(), + false, + false, + true, + 1, + properties.maxPayloadBytes(), + properties.maxTtl()); + } + + @Override + public CompletionStage submit(ProviderSubmission submission) { + Objects.requireNonNull(submission, "submission"); + return CompletableFuture.completedFuture(send(submission)); + } + + private ProviderSubmissionResult send(ProviderSubmission submission) { + long startedNanos = System.nanoTime(); + ContactPointValue value = + protector.reveal( + submission.contactPoint(), + AccessContext.dispatch(submission.profile().profileId().value())); + if (!(value instanceof WebPushSubscriptionValue subscription)) { + throw new IllegalArgumentException("Web Push requires a subscription contact point"); + } + + var request = mapper.map(submission, subscription); + try { + NotificationHttpResponse response = gateway.exchange(request); + Duration elapsed = Duration.ofNanos(System.nanoTime() - startedNanos); + if (response.isSuccessful()) { + return ProviderSubmissionResult.accepted( + response.header("location").orElse(null), + Integer.toString(response.statusCode()), + elapsed); + } + return ProviderSubmissionResult.rejected(classifier.classify(response), elapsed); + } catch (NotificationHttpTransportException transportFailure) { + return ProviderResults.fromTransport( + transportFailure, Duration.ofNanos(System.nanoTime() - startedNanos)); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushProviderProperties.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushProviderProperties.java new file mode 100644 index 00000000..066b705c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushProviderProperties.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import java.time.Duration; +import java.util.Objects; + +/** + * Web Push profile. + * + *

{@code receiptsSupported} defaults to false. RFC 8030 defines delivery receipts, but not every + * push service implements them, and assuming one exists would mean waiting forever for a receipt + * that is never coming. + */ +public record WebPushProviderProperties( + String vapidPublicKeyBase64Url, + Duration maxTtl, + long maxPayloadBytes, + boolean receiptsSupported, + Duration timeout) { + + /** RFC 8291 does not require a push service to accept more than this. */ + public static final long RFC_8291_MAX_BODY_BYTES = 4096L; + + public WebPushProviderProperties { + Objects.requireNonNull(vapidPublicKeyBase64Url, "vapidPublicKeyBase64Url"); + Objects.requireNonNull(maxTtl, "maxTtl"); + Objects.requireNonNull(timeout, "timeout"); + if (vapidPublicKeyBase64Url.isBlank()) { + throw new IllegalArgumentException("vapidPublicKeyBase64Url"); + } + if (maxPayloadBytes < 1 || maxPayloadBytes > RFC_8291_MAX_BODY_BYTES) { + throw new IllegalArgumentException("maxPayloadBytes must be 1.." + RFC_8291_MAX_BODY_BYTES); + } + if (maxTtl.isNegative() || maxTtl.isZero() || timeout.isNegative() || timeout.isZero()) { + throw new IllegalArgumentException("maxTtl and timeout must be positive and finite"); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushReceiptCapability.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushReceiptCapability.java new file mode 100644 index 00000000..36f00d5c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushReceiptCapability.java @@ -0,0 +1,97 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import java.net.URI; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; + +/** + * RFC 8030 §8 delivery receipts, treated as optional because they are. + * + *

A receipt subscription is the only way Web Push can report {@code DEVICE_DELIVERED} rather + * than {@code PROVIDER_ACCEPTED}. But RFC 8030 does not require a push service to implement it, and + * the major ones largely do not — so the capability has to be declared per profile and confirmed by + * the response, never assumed. Assuming it would leave deliveries parked forever waiting on a + * receipt that is not coming, which is worse than honestly reporting acceptance. + */ +public final class WebPushReceiptCapability { + + /** Link relation a push service uses to hand back a receipt subscription. */ + public static final String RECEIPT_LINK_RELATION = "urn:ietf:params:push:receipt"; + + private final boolean requested; + + private WebPushReceiptCapability(boolean requested) { + this.requested = requested; + } + + /** Capability derived from the profile's declared support. */ + public static WebPushReceiptCapability forProfile(WebPushProviderProperties properties) { + return new WebPushReceiptCapability( + Objects.requireNonNull(properties, "properties").receiptsSupported()); + } + + /** Never request receipts. */ + public static WebPushReceiptCapability unsupported() { + return new WebPushReceiptCapability(false); + } + + /** True when a receipt should be requested for this profile. */ + public boolean requested() { + return requested; + } + + /** + * The {@code Prefer} header value, if any. + * + *

Empty rather than a no-op header: sending {@code Prefer: respond-async} to a service that + * does not implement receipts invites a 4xx from strict implementations for no benefit. + */ + public Optional preferHeader() { + return requested ? Optional.of("respond-async") : Optional.empty(); + } + + /** + * Receipt subscription URI advertised by the push service, if it advertised one. + * + *

A response that carries no receipt link is the normal case, not an error. It means the send + * was accepted and delivery evidence will never rise above acceptance for this message. + * + * @param linkHeaders raw {@code Link} response header values + */ + public Optional receiptSubscription(List linkHeaders) { + Objects.requireNonNull(linkHeaders, "linkHeaders"); + if (!requested) { + return Optional.empty(); + } + for (String header : linkHeaders) { + for (String link : header.split(",", -1)) { + Optional receipt = parseReceiptLink(link); + if (receipt.isPresent()) { + return receipt; + } + } + } + return Optional.empty(); + } + + private static Optional parseReceiptLink(String link) { + String candidate = link.trim(); + int start = candidate.indexOf('<'); + int end = candidate.indexOf('>'); + if (start < 0 || end <= start) { + return Optional.empty(); + } + String parameters = candidate.substring(end + 1).toLowerCase(Locale.ROOT).replace("\"", ""); + if (!parameters.contains("rel=" + RECEIPT_LINK_RELATION)) { + return Optional.empty(); + } + try { + return Optional.of(URI.create(candidate.substring(start + 1, end).trim())); + } catch (IllegalArgumentException malformed) { + // A malformed link is not worth failing an accepted send over; it only costs the receipt. + return Optional.empty(); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushRequestMapper.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushRequestMapper.java new file mode 100644 index 00000000..48e6a304 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushRequestMapper.java @@ -0,0 +1,117 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest; +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.api.content.WebPushContent; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationExpiredException; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.ProviderConfigurationException; +import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException; +import dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Builds the RFC 8030 request. + * + *

{@code TTL} is mandatory by protocol, and a submission with no expiry cannot produce one. That + * is a configuration failure rather than a silent default, because a guessed TTL would decide how + * long a push service keeps a message the platform has no opinion about. + */ +public final class WebPushRequestMapper { + + private final Rfc8291Aes128GcmEncryptor encryptor; + private final VapidAuthorizationProvider signer; + private final SecretMaterialProvider secrets; + private final WebPushProviderProperties properties; + private final Clock clock; + + public WebPushRequestMapper( + Rfc8291Aes128GcmEncryptor encryptor, + VapidAuthorizationProvider signer, + SecretMaterialProvider secrets, + WebPushProviderProperties properties, + Clock clock) { + this.encryptor = Objects.requireNonNull(encryptor, "encryptor"); + this.signer = Objects.requireNonNull(signer, "signer"); + this.secrets = Objects.requireNonNull(secrets, "secrets"); + this.properties = Objects.requireNonNull(properties, "properties"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + /** Map one submission into a Web Push request. */ + public NotificationHttpRequest map( + ProviderSubmission submission, WebPushSubscriptionValue subscription) { + Objects.requireNonNull(submission, "submission"); + Objects.requireNonNull(subscription, "subscription"); + + if (submission.expiresAt().isEmpty()) { + throw new ProviderConfigurationException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID, + FailureCategory.INVALID_PAYLOAD)); + } + Duration ttl = Duration.between(clock.instant(), submission.expiresAt().get()); + if (ttl.isNegative() || ttl.isZero()) { + throw new NotificationExpiredException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.NOTIFICATION_EXPIRED, FailureCategory.EXPIRED)); + } + if (ttl.compareTo(properties.maxTtl()) > 0) { + ttl = properties.maxTtl(); + } + + if (!(submission.content().content() instanceof WebPushContent content)) { + throw new IllegalArgumentException("Web Push requires Web Push content"); + } + Map payload = new LinkedHashMap<>(); + payload.put("title", content.title()); + payload.put("body", content.body()); + content.deepLink().ifPresent(link -> payload.put("deepLink", link.toString())); + if (!content.data().isEmpty()) { + payload.put("data", content.data()); + } + + var encrypted = + encryptor.encrypt( + subscription, + NotificationJsonMapper.mapper() + .writeValueAsString(payload) + .getBytes(StandardCharsets.UTF_8)); + if (encrypted.body().length > properties.maxPayloadBytes()) { + throw new ProviderPayloadLimitException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT, FailureCategory.INVALID_PAYLOAD)); + } + + Map headers = new LinkedHashMap<>(); + headers.put("ttl", Long.toString(ttl.toSeconds())); + headers.put("content-encoding", encrypted.contentEncoding()); + headers.put("content-type", "application/octet-stream"); + headers.put("urgency", content.options().urgency().headerValue()); + content.options().topic().ifPresent(topic -> headers.put("topic", topic)); + headers.put( + "authorization", + signer.authorization( + subscription.endpoint(), + secrets.activeKey(SecretPurpose.VAPID_SIGNING), + properties.vapidPublicKeyBase64Url())); + + return new NotificationHttpRequest( + "POST", + subscription.endpoint(), + JdkNotificationHttpGateway.headers(headers), + encrypted.body(), + properties.timeout()); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactiveNotificationOrchestrator.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactiveNotificationOrchestrator.java new file mode 100644 index 00000000..bc130299 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactiveNotificationOrchestrator.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.outbound.notification.platform.reactor; + +import dev.caskeleton.application.notification.platform.api.NotificationId; +import dev.caskeleton.application.notification.platform.api.NotificationPlan; +import dev.caskeleton.application.notification.platform.api.NotificationReceipt; +import dev.caskeleton.application.notification.platform.api.NotificationSnapshot; +import java.time.Instant; +import reactor.core.publisher.Mono; + +/** + * Optional Reactor facade. + * + *

It exists as a separate type so that Reactor stays out of the core contract: the platform's + * asynchronous type is {@code CompletionStage}, and an application that does not use Reactor never + * sees it. + */ +public interface ReactiveNotificationOrchestrator { + + /** Accept a plan. */ + Mono submit(NotificationPlan plan); + + /** Accept a scheduled plan. */ + Mono schedule(NotificationPlan plan, Instant scheduleAt); + + /** Read the projection of a notification. */ + Mono get(NotificationId notificationId); +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactorContextBridge.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactorContextBridge.java new file mode 100644 index 00000000..4be68bc2 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactorContextBridge.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.notification.platform.reactor; + +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; +import reactor.util.context.ContextView; + +/** + * Carries selected Reactor context values across the blocking boundary. + * + *

Only the correlation id crosses. A general context copy would let request-scoped state leak + * into a worker thread that outlives the request. + */ +public final class ReactorContextBridge { + + /** Reactor context key for the correlation id. */ + public static final String CORRELATION_ID = "correlationId"; + + private final ThreadLocal currentCorrelationId = new ThreadLocal<>(); + + /** Run an action with the context's correlation id bound to the calling thread. */ + public T withContext(ContextView context, Supplier action) { + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(action, "action"); + Optional correlationId = + context.hasKey(CORRELATION_ID) + ? Optional.of(String.valueOf(context.get(CORRELATION_ID))) + : Optional.empty(); + correlationId.ifPresent(currentCorrelationId::set); + try { + return action.get(); + } finally { + currentCorrelationId.remove(); + } + } + + /** Correlation id bound to the current thread, if any. */ + public Optional currentCorrelationId() { + return Optional.ofNullable(currentCorrelationId.get()); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactorNotificationOrchestrator.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactorNotificationOrchestrator.java new file mode 100644 index 00000000..45e34dc3 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactorNotificationOrchestrator.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.outbound.notification.platform.reactor; + +import dev.caskeleton.application.notification.platform.api.NotificationId; +import dev.caskeleton.application.notification.platform.api.NotificationOrchestrator; +import dev.caskeleton.application.notification.platform.api.NotificationPlan; +import dev.caskeleton.application.notification.platform.api.NotificationReceipt; +import dev.caskeleton.application.notification.platform.api.NotificationSnapshot; +import java.time.Instant; +import java.util.Objects; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; + +/** + * Reactor facade over the synchronous durable API. + * + *

The blocking submit runs on {@code boundedElastic}, never on an event loop, and the facade + * itself never calls {@code block()}. + * + *

Cancelling the {@code Mono} stops the caller waiting; it does not delete a notification that + * has already been committed. Undoing a durable acceptance because a subscriber went away would + * make the receipt meaningless. + */ +public final class ReactorNotificationOrchestrator implements ReactiveNotificationOrchestrator { + + private final NotificationOrchestrator delegate; + private final ReactorContextBridge contextBridge; + private final Scheduler scheduler; + + public ReactorNotificationOrchestrator( + NotificationOrchestrator delegate, ReactorContextBridge contextBridge) { + this(delegate, contextBridge, Schedulers.boundedElastic()); + } + + public ReactorNotificationOrchestrator( + NotificationOrchestrator delegate, ReactorContextBridge contextBridge, Scheduler scheduler) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.contextBridge = Objects.requireNonNull(contextBridge, "contextBridge"); + this.scheduler = Objects.requireNonNull(scheduler, "scheduler"); + } + + @Override + public Mono submit(NotificationPlan plan) { + Objects.requireNonNull(plan, "plan"); + return Mono.deferContextual( + context -> + Mono.fromSupplier( + () -> contextBridge.withContext(context, () -> delegate.submit(plan)))) + .subscribeOn(scheduler); + } + + @Override + public Mono schedule(NotificationPlan plan, Instant scheduleAt) { + Objects.requireNonNull(plan, "plan"); + Objects.requireNonNull(scheduleAt, "scheduleAt"); + return Mono.deferContextual( + context -> + Mono.fromSupplier( + () -> + contextBridge.withContext( + context, () -> delegate.schedule(plan, scheduleAt)))) + .subscribeOn(scheduler); + } + + @Override + public Mono get(NotificationId notificationId) { + Objects.requireNonNull(notificationId, "notificationId"); + return Mono.deferContextual( + context -> + Mono.fromSupplier( + () -> contextBridge.withContext(context, () -> delegate.get(notificationId)))) + .subscribeOn(scheduler); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmCallbackPayloadProtection.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmCallbackPayloadProtection.java new file mode 100644 index 00000000..8443cc0c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmCallbackPayloadProtection.java @@ -0,0 +1,118 @@ +package dev.caskeleton.adapter.outbound.notification.platform.security; + +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationDigest; +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.callback.CallbackPayloadProtectionPort; +import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.Objects; +import java.util.TreeMap; +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * Bounded, encrypted retention of raw callback payloads. + * + *

The raw payload is kept because a normalization bug is only diagnosable against what the + * provider actually sent — but it routinely contains addresses and message metadata, so it is + * encrypted and truncated rather than stored as received. + * + *

The nonce is prefixed to the ciphertext so a rotation does not need a second column, and the + * fingerprint is keyed so that providers without an event id still get collision-resistant, + * non-enumerable duplicate detection. + */ +public final class AesGcmCallbackPayloadProtection implements CallbackPayloadProtectionPort { + + private static final int NONCE_BYTES = 12; + private static final int TAG_BITS = 128; + + private final SecretMaterialProvider secrets; + private final SecureRandom random; + private final int maxRetainedBytes; + + public AesGcmCallbackPayloadProtection(SecretMaterialProvider secrets, int maxRetainedBytes) { + this(secrets, new SecureRandom(), maxRetainedBytes); + } + + AesGcmCallbackPayloadProtection( + SecretMaterialProvider secrets, SecureRandom random, int maxRetainedBytes) { + this.secrets = Objects.requireNonNull(secrets, "secrets"); + this.random = Objects.requireNonNull(random, "random"); + this.maxRetainedBytes = maxRetainedBytes; + if (maxRetainedBytes < 1) { + throw new IllegalArgumentException("maxRetainedBytes"); + } + } + + @Override + public byte[] protectRawPayload(byte[] rawBody) { + Objects.requireNonNull(rawBody, "rawBody"); + byte[] bounded = + rawBody.length <= maxRetainedBytes ? rawBody : Arrays.copyOf(rawBody, maxRetainedBytes); + byte[] nonce = new byte[NONCE_BYTES]; + random.nextBytes(nonce); + try { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init( + Cipher.ENCRYPT_MODE, + new SecretKeySpec(secrets.activeKey(SecretPurpose.PAYLOAD_ENCRYPTION).material(), "AES"), + new GCMParameterSpec(TAG_BITS, nonce)); + byte[] ciphertext = cipher.doFinal(bounded); + byte[] stored = new byte[nonce.length + ciphertext.length]; + System.arraycopy(nonce, 0, stored, 0, nonce.length); + System.arraycopy(ciphertext, 0, stored, nonce.length, ciphertext.length); + return stored; + } catch (GeneralSecurityException failure) { + throw new IllegalStateException("callback payload encryption failed", failure); + } + } + + @Override + public String digest(byte[] rawBody) { + Objects.requireNonNull(rawBody, "rawBody"); + return NotificationDigest.hex(rawBody); + } + + @Override + public String fingerprint( + ProviderProfileId profileId, NormalizedProviderEvent event, String rawPayloadDigest) { + Objects.requireNonNull(profileId, "profileId"); + Objects.requireNonNull(event, "event"); + Objects.requireNonNull(rawPayloadDigest, "rawPayloadDigest"); + + // Everything that distinguishes two genuinely different events goes into the input; arrival + // time deliberately does not, or a redelivery would look like a new event. + String seed = + profileId.value() + + '\u001f' + + event.type().name() + + '\u001f' + + event.providerNativeType() + + '\u001f' + + event.providerRequestId().orElse("-") + + '\u001f' + + event.providerOccurredAt().map(Object::toString).orElse("-") + + '\u001f' + + new TreeMap<>(event.attributes()) + + '\u001f' + + rawPayloadDigest; + + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init( + new SecretKeySpec( + secrets.activeKey(SecretPurpose.CONTACT_LOOKUP_HMAC).material(), "HmacSHA256")); + return HexFormat.of().formatHex(mac.doFinal(seed.getBytes(StandardCharsets.UTF_8))); + } catch (GeneralSecurityException failure) { + throw new IllegalStateException("callback fingerprinting failed", failure); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmContactPointProtector.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmContactPointProtector.java new file mode 100644 index 00000000..ebe5e8f0 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmContactPointProtector.java @@ -0,0 +1,204 @@ +package dev.caskeleton.adapter.outbound.notification.platform.security; + +import dev.caskeleton.application.notification.platform.contact.ApnsDeviceToken; +import dev.caskeleton.application.notification.platform.contact.ApnsEnvironment; +import dev.caskeleton.application.notification.platform.contact.ContactPointType; +import dev.caskeleton.application.notification.platform.contact.ContactPointValue; +import dev.caskeleton.application.notification.platform.contact.EmailAddress; +import dev.caskeleton.application.notification.platform.contact.FcmInstallationId; +import dev.caskeleton.application.notification.platform.contact.InAppRecipientRef; +import dev.caskeleton.application.notification.platform.contact.LegacyFcmRegistrationToken; +import dev.caskeleton.application.notification.platform.contact.PhoneNumber; +import dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue; +import dev.caskeleton.application.notification.platform.security.AccessContext; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import dev.caskeleton.application.notification.platform.security.ProtectedContactPoint; +import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HexFormat; +import java.util.Objects; +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * AES-256-GCM encryption with a separate HMAC-SHA-256 lookup fingerprint. + * + *

Two keys, not one. The ciphertext must be non-deterministic so that two records of the same + * address are not visibly identical, while equality lookup must be deterministic — those are + * opposite requirements, and one key cannot serve both without leaking one of them. + * + *

The fingerprint is keyed rather than a plain digest because email addresses and phone numbers + * come from a small, guessable space: an unkeyed hash of a phone number is recoverable by + * enumeration in seconds. + */ +public final class AesGcmContactPointProtector implements ContactPointProtector { + + private static final String CIPHER = "AES/GCM/NoPadding"; + private static final String HMAC = "HmacSHA256"; + private static final String KEY_ALGORITHM = "AES"; + private static final int NONCE_BYTES = 12; + private static final int TAG_BITS = 128; + private static final int REQUIRED_KEY_BITS = 256; + + private final SecretMaterialProvider keys; + private final SecureRandom random; + + public AesGcmContactPointProtector(SecretMaterialProvider keys) { + this(keys, new SecureRandom()); + } + + AesGcmContactPointProtector(SecretMaterialProvider keys, SecureRandom random) { + this.keys = Objects.requireNonNull(keys, "keys"); + this.random = Objects.requireNonNull(random, "random"); + } + + @Override + public ProtectedContactPoint protect(ContactPointValue value) { + Objects.requireNonNull(value, "value"); + SecretKeyMaterial encryption = requireEncryptionKey(); + SecretKeyMaterial lookup = keys.activeKey(SecretPurpose.CONTACT_LOOKUP_HMAC); + requireDistinctKeys(encryption, lookup); + + byte[] nonce = new byte[NONCE_BYTES]; + random.nextBytes(nonce); + byte[] plaintext = value.normalized().getBytes(StandardCharsets.UTF_8); + byte[] ciphertext = encrypt(encryption, nonce, associatedData(value.type()), plaintext); + + return new ProtectedContactPoint( + value.type(), + encryption.keyId(), + nonce, + ciphertext, + fingerprint(lookup, value.type(), value.normalized())); + } + + @Override + public ContactPointValue reveal(ProtectedContactPoint protectedValue, AccessContext context) { + Objects.requireNonNull(protectedValue, "protectedValue"); + Objects.requireNonNull(context, "context"); + SecretKeyMaterial key = keys.keyById(protectedValue.keyId()); + byte[] plaintext = + decrypt( + key, + protectedValue.nonce(), + associatedData(protectedValue.type()), + protectedValue.ciphertext()); + return parse(protectedValue.type(), new String(plaintext, StandardCharsets.UTF_8)); + } + + @Override + public String fingerprint(ContactPointValue value) { + Objects.requireNonNull(value, "value"); + return fingerprint( + keys.activeKey(SecretPurpose.CONTACT_LOOKUP_HMAC), value.type(), value.normalized()); + } + + private SecretKeyMaterial requireEncryptionKey() { + SecretKeyMaterial encryption = keys.activeKey(SecretPurpose.CONTACT_ENCRYPTION); + if (encryption.lengthBits() != REQUIRED_KEY_BITS) { + throw new IllegalArgumentException( + "contact encryption key must be " + REQUIRED_KEY_BITS + " bits"); + } + return encryption; + } + + private static void requireDistinctKeys(SecretKeyMaterial encryption, SecretKeyMaterial lookup) { + if (encryption.keyId().equals(lookup.keyId()) + || java.security.MessageDigest.isEqual(encryption.material(), lookup.material())) { + throw new IllegalArgumentException("encryption and HMAC keys must differ"); + } + } + + private static byte[] associatedData(ContactPointType type) { + // Binding the type into the AAD means a ciphertext cannot be moved between contact point kinds + // without the tag check failing. + return ("contact-point:" + type.name()).getBytes(StandardCharsets.UTF_8); + } + + private static byte[] encrypt( + SecretKeyMaterial key, byte[] nonce, byte[] associatedData, byte[] plaintext) { + try { + Cipher cipher = Cipher.getInstance(CIPHER); + cipher.init( + Cipher.ENCRYPT_MODE, + new SecretKeySpec(key.material(), KEY_ALGORITHM), + new GCMParameterSpec(TAG_BITS, nonce)); + cipher.updateAAD(associatedData); + return cipher.doFinal(plaintext); + } catch (GeneralSecurityException failure) { + // The message deliberately carries no plaintext and no key material. + throw new IllegalStateException("contact point encryption failed", failure); + } + } + + private static byte[] decrypt( + SecretKeyMaterial key, byte[] nonce, byte[] associatedData, byte[] ciphertext) { + try { + Cipher cipher = Cipher.getInstance(CIPHER); + cipher.init( + Cipher.DECRYPT_MODE, + new SecretKeySpec(key.material(), KEY_ALGORITHM), + new GCMParameterSpec(TAG_BITS, nonce)); + cipher.updateAAD(associatedData); + return cipher.doFinal(ciphertext); + } catch (GeneralSecurityException failure) { + throw new IllegalStateException("contact point decryption failed", failure); + } + } + + private static String fingerprint( + SecretKeyMaterial key, ContactPointType type, String normalized) { + try { + Mac mac = Mac.getInstance(HMAC); + mac.init(new SecretKeySpec(key.material(), HMAC)); + mac.update((type.name() + ":").getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(mac.doFinal(normalized.getBytes(StandardCharsets.UTF_8))); + } catch (GeneralSecurityException failure) { + throw new IllegalStateException("contact point fingerprinting failed", failure); + } + } + + private static ContactPointValue parse(ContactPointType type, String normalized) { + return switch (type) { + case EMAIL -> EmailAddress.parse(normalized); + case PHONE -> new PhoneNumber(normalized); + case FCM_FID -> new FcmInstallationId(normalized); + case FCM_REGISTRATION_TOKEN_LEGACY -> new LegacyFcmRegistrationToken(normalized); + case APNS_DEVICE_TOKEN -> parseApns(normalized); + case WEB_PUSH_SUBSCRIPTION -> parseWebPush(normalized); + case IN_APP_RECIPIENT -> new InAppRecipientRef(normalized); + }; + } + + private static ApnsDeviceToken parseApns(String normalized) { + int separator = normalized.indexOf(':'); + if (separator <= 0) { + throw new IllegalStateException("stored APNs token is missing its environment"); + } + return new ApnsDeviceToken( + normalized.substring(separator + 1), + ApnsEnvironment.valueOf(normalized.substring(0, separator))); + } + + private static WebPushSubscriptionValue parseWebPush(String normalized) { + int separator = normalized.indexOf('|'); + if (separator <= 0) { + throw new IllegalStateException("stored Web Push subscription is malformed"); + } + // The auth secret and VAPID key id are stored in their own encrypted columns; the normalized + // form only has to round-trip the equality-relevant parts. + return new WebPushSubscriptionValue( + URI.create(normalized.substring(0, separator)), + Base64.getUrlDecoder().decode(normalized.substring(separator + 1)), + new byte[16], + "restored"); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/CredentialGeneration.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/CredentialGeneration.java new file mode 100644 index 00000000..5a214c56 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/CredentialGeneration.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.outbound.notification.platform.security; + +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * One numbered credential generation of a provider profile. + * + *

This is a reference to credential material, not the material. The generation number + * and the key id are the only two facts that may appear in an audit record or a metric tag; the + * bytes stay behind {@link dev.caskeleton.application.notification.platform.security + * .SecretMaterialProvider} and are fetched at use time. That split is what lets a rotation be fully + * auditable without the audit trail itself becoming a place secrets accumulate. + * + *

Generations are strictly increasing per profile. A rotation that reused or decreased the + * number would make an attempt record ambiguous about which credential actually signed it, which is + * exactly the question an incident asks first. + * + * @param profileId profile this generation belongs to + * @param generation strictly increasing generation number, starting at 1 + * @param keyId secret-manager key id backing this generation + * @param activatedAt when the generation was cut over, absent for a candidate + */ +public record CredentialGeneration( + ProviderProfileId profileId, long generation, String keyId, Optional activatedAt) { + + public CredentialGeneration { + Objects.requireNonNull(profileId, "profileId"); + Objects.requireNonNull(keyId, "keyId"); + Objects.requireNonNull(activatedAt, "activatedAt"); + if (generation < 1) { + throw new IllegalArgumentException("generation"); + } + if (keyId.isBlank()) { + throw new IllegalArgumentException("keyId"); + } + } + + /** A candidate generation that has not been activated yet. */ + public static CredentialGeneration candidate( + ProviderProfileId profileId, long generation, String keyId) { + return new CredentialGeneration(profileId, generation, keyId, Optional.empty()); + } + + /** The same generation, marked active as of {@code at}. */ + public CredentialGeneration activatedAt(Instant at) { + return new CredentialGeneration( + profileId, generation, keyId, Optional.of(Objects.requireNonNull(at, "at"))); + } + + /** True when this generation supersedes {@code other}. */ + public boolean supersedes(CredentialGeneration other) { + Objects.requireNonNull(other, "other"); + return profileId.equals(other.profileId) && generation > other.generation; + } + + /** + * Bounded audit form. + * + *

Deliberately excludes everything except the profile, the number and the key id — a key id is + * a handle, not a secret, and it is the field an operator needs to correlate a rotation with the + * secret manager's own log. + */ + public String auditForm() { + return profileId.value() + "#" + generation + "/" + keyId; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/HmacProviderRequestIdHasher.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/HmacProviderRequestIdHasher.java new file mode 100644 index 00000000..355169d1 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/HmacProviderRequestIdHasher.java @@ -0,0 +1,47 @@ +package dev.caskeleton.adapter.outbound.notification.platform.security; + +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.dispatch.ProviderRequestIdHasherPort; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.HexFormat; +import java.util.Objects; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Keyed hash of a provider request id. + * + *

The profile is mixed into the input, so the same identifier issued by two providers hashes + * differently and an event cannot attach itself to the wrong attempt. + * + *

Keyed rather than a plain digest: provider identifiers are short and structured, so an unkeyed + * hash of the whole table is reversible by anyone who obtains it. + */ +public final class HmacProviderRequestIdHasher implements ProviderRequestIdHasherPort { + + private final SecretMaterialProvider secrets; + + public HmacProviderRequestIdHasher(SecretMaterialProvider secrets) { + this.secrets = Objects.requireNonNull(secrets, "secrets"); + } + + @Override + public String hash(ProviderProfileId profileId, String providerRequestId) { + Objects.requireNonNull(profileId, "profileId"); + Objects.requireNonNull(providerRequestId, "providerRequestId"); + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init( + new SecretKeySpec( + secrets.activeKey(SecretPurpose.CONTACT_LOOKUP_HMAC).material(), "HmacSHA256")); + mac.update((profileId.value() + ":").getBytes(StandardCharsets.UTF_8)); + return HexFormat.of() + .formatHex(mac.doFinal(providerRequestId.getBytes(StandardCharsets.UTF_8))); + } catch (GeneralSecurityException failure) { + throw new IllegalStateException("provider request id hashing failed", failure); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/ProviderCredentialManager.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/ProviderCredentialManager.java new file mode 100644 index 00000000..4273da28 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/ProviderCredentialManager.java @@ -0,0 +1,104 @@ +package dev.caskeleton.adapter.outbound.notification.platform.security; + +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.time.Clock; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks which credential generation is current for each provider profile. + * + *

Two rotations are deliberately not handled here, because treating them as ordinary + * credential swaps would silently lose data or delivery: + * + *

+ */ +public final class ProviderCredentialManager { + + private final SecretMaterialProvider secrets; + private final Clock clock; + private final Map current = new ConcurrentHashMap<>(); + + public ProviderCredentialManager(SecretMaterialProvider secrets, Clock clock) { + this.secrets = Objects.requireNonNull(secrets, "secrets"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + /** Record the generation a profile starts on. */ + public CredentialGeneration activate(CredentialGeneration generation) { + Objects.requireNonNull(generation, "generation"); + CredentialGeneration existing = current.get(generation.profileId()); + if (existing != null && !generation.supersedes(existing)) { + throw new IllegalArgumentException("generation does not supersede the active one"); + } + // Fetch once at activation so a key id that does not resolve fails the rotation instead of + // failing the first notification that happens to use the profile. + requireResolvable(generation); + CredentialGeneration activated = generation.activatedAt(clock.instant()); + current.put(activated.profileId(), activated); + return activated; + } + + /** Current generation of a profile. */ + public Optional current(ProviderProfileId profileId) { + return Optional.ofNullable(current.get(Objects.requireNonNull(profileId, "profileId"))); + } + + /** The next candidate number for a profile. */ + public long nextGenerationNumber(ProviderProfileId profileId) { + return current(profileId).map(CredentialGeneration::generation).orElse(0L) + 1; + } + + /** + * Credential material for a generation. + * + *

Resolved per call rather than cached in a field. A cached credential outlives the rotation + * that replaced it, and the resulting attempt is attributed to a generation that is no longer + * current. + */ + public SecretKeyMaterial material(CredentialGeneration generation) { + Objects.requireNonNull(generation, "generation"); + SecretKeyMaterial key = secrets.keyById(generation.keyId()); + if (key.purpose() != SecretPurpose.PROVIDER_CREDENTIAL) { + throw new IllegalStateException("key is not a provider credential"); + } + return key; + } + + /** + * Refuse a rotation that this manager must not perform. + * + * @throws IllegalArgumentException always, naming the required migration instead + */ + public static void rejectManagedRotation(SecretPurpose purpose) { + Objects.requireNonNull(purpose, "purpose"); + throw new IllegalArgumentException( + switch (purpose) { + case CONTACT_ENCRYPTION -> + "contact encryption key rotation requires a background re-encryption job"; + case VAPID_SIGNING -> + "VAPID key rotation requires subscription migration by the user agent"; + default -> "purpose is not rotated through the provider credential manager: " + purpose; + }); + } + + private void requireResolvable(CredentialGeneration generation) { + SecretKeyMaterial key = secrets.keyById(generation.keyId()); + if (key.purpose() != SecretPurpose.PROVIDER_CREDENTIAL) { + throw new IllegalArgumentException("key is not a provider credential"); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/SettingsSecretMaterialProvider.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/SettingsSecretMaterialProvider.java new file mode 100644 index 00000000..ab92af84 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/SettingsSecretMaterialProvider.java @@ -0,0 +1,49 @@ +package dev.caskeleton.adapter.outbound.notification.platform.security; + +import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.util.Map; +import java.util.Objects; + +/** + * Secret provider backed by material supplied at composition time. + * + *

Values arrive from the environment or a secret manager through the composition root. Nothing + * here reads a file or a configuration property directly, so rotating a key never means editing a + * deployed artifact. + */ +public final class SettingsSecretMaterialProvider implements SecretMaterialProvider { + + private final Map active; + private final Map byKeyId; + + public SettingsSecretMaterialProvider( + Map active, Map historical) { + this.active = Map.copyOf(Objects.requireNonNull(active, "active")); + Objects.requireNonNull(historical, "historical"); + java.util.Map all = new java.util.HashMap<>(historical); + active.values().forEach(key -> all.put(key.keyId(), key)); + this.byKeyId = Map.copyOf(all); + } + + @Override + public SecretKeyMaterial activeKey(SecretPurpose purpose) { + SecretKeyMaterial key = active.get(purpose); + if (key == null) { + throw new IllegalStateException("no active key configured for purpose " + purpose); + } + return key; + } + + @Override + public SecretKeyMaterial keyById(String keyId) { + SecretKeyMaterial key = byKeyId.get(keyId); + if (key == null) { + // Refusing here is what makes key rotation safe: silently falling back to the current key + // would turn every historical row into an authentication-tag failure at read time. + throw new IllegalStateException("unknown key id"); + } + return key; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/CanonicalNotificationRenderer.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/CanonicalNotificationRenderer.java new file mode 100644 index 00000000..e5a673b9 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/CanonicalNotificationRenderer.java @@ -0,0 +1,166 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import dev.caskeleton.application.notification.platform.api.content.EmailContent; +import dev.caskeleton.application.notification.platform.api.content.EmailOptions; +import dev.caskeleton.application.notification.platform.api.content.InAppContent; +import dev.caskeleton.application.notification.platform.api.content.MobilePushContent; +import dev.caskeleton.application.notification.platform.api.content.NotificationContent; +import dev.caskeleton.application.notification.platform.api.content.PushPresentation; +import dev.caskeleton.application.notification.platform.api.content.SmsContent; +import dev.caskeleton.application.notification.platform.api.content.SmsOptions; +import dev.caskeleton.application.notification.platform.api.content.WebPushContent; +import dev.caskeleton.application.notification.platform.api.content.WebPushOptions; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.template.NotificationTemplateRenderer; +import dev.caskeleton.application.notification.platform.template.NotificationTemplateVersion; +import dev.caskeleton.application.notification.platform.template.RenderCommand; +import dev.caskeleton.application.notification.platform.template.RenderedNotificationContent; +import dev.caskeleton.application.notification.platform.template.TemplateRegistry; +import dev.caskeleton.application.notification.platform.template.TemplateSlot; +import dev.caskeleton.application.notification.platform.template.TemplateVariableValidator; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * The reference renderer for every stable channel. + * + *

Rendering is deterministic and the digest covers channel, template coordinate, resolved locale + * and every rendered field. That digest is stored on the attempt, which is what lets a retry prove + * it sent the same content and lets a redrive re-execute the original notification rather than a + * newly rendered one. + * + *

Variables are validated first, so a schema violation costs nothing at the provider. + */ +public final class CanonicalNotificationRenderer implements NotificationTemplateRenderer { + + private final Channel channel; + private final TemplateRegistry templates; + private final TemplateVariableValidator validator; + private final NotificationTemplateEngine engine; + + public CanonicalNotificationRenderer( + Channel channel, + TemplateRegistry templates, + TemplateVariableValidator validator, + NotificationTemplateEngine engine) { + this.channel = Objects.requireNonNull(channel, "channel"); + this.templates = Objects.requireNonNull(templates, "templates"); + this.validator = Objects.requireNonNull(validator, "validator"); + this.engine = Objects.requireNonNull(engine, "engine"); + } + + @Override + public Channel channel() { + return channel; + } + + @Override + public RenderedNotificationContent render(RenderCommand command) { + Objects.requireNonNull(command, "command"); + + NotificationTemplateVersion template = + templates.resolve( + command.selection().templateId(), + command.selection().version(), + command.channel(), + command.requestedLocale()); + validator.validate(template.variableSchema(), command.variables()); + + NotificationContent content = buildContent(template, command.variables()); + String digest = + NotificationDigest.hex( + command.channel().name() + + '\u001f' + + template.templateId() + + '\u001f' + + template.version() + + '\u001f' + + template.locale().toLanguageTag() + + '\u001f' + + canonicalForm(content)); + return new RenderedNotificationContent(content, digest, command.selection(), template.locale()); + } + + private NotificationContent buildContent( + NotificationTemplateVersion template, Map variables) { + return switch (channel) { + case EMAIL -> + new EmailContent( + slot(template, TemplateSlot.SUBJECT, variables), + slot(template, TemplateSlot.TEXT_BODY, variables), + optionalSlot(template, TemplateSlot.HTML_BODY, variables), + List.of(), + EmailOptions.DEFAULT); + case SMS -> + new SmsContent(slot(template, TemplateSlot.TEXT_BODY, variables), SmsOptions.DEFAULT); + case PUSH -> + new MobilePushContent( + slot(template, TemplateSlot.TITLE, variables), + slot(template, TemplateSlot.BODY, variables), + optionalSlot(template, TemplateSlot.DEEP_LINK, variables).map(URI::create), + Map.of(), + PushPresentation.DEFAULT); + case WEB_PUSH -> + new WebPushContent( + slot(template, TemplateSlot.TITLE, variables), + slot(template, TemplateSlot.BODY, variables), + optionalSlot(template, TemplateSlot.DEEP_LINK, variables).map(URI::create), + Map.of(), + WebPushOptions.DEFAULT); + case IN_APP, WEBHOOK -> + new InAppContent( + slot(template, TemplateSlot.TITLE, variables), + slot(template, TemplateSlot.BODY, variables), + optionalSlot(template, TemplateSlot.DEEP_LINK, variables).map(URI::create), + List.of(), + optionalSlot(template, TemplateSlot.CATEGORY, variables).orElse("general")); + }; + } + + private String slot( + NotificationTemplateVersion template, TemplateSlot slot, Map variables) { + return engine.render(template.content().requireSlot(slot), variables); + } + + private Optional optionalSlot( + NotificationTemplateVersion template, TemplateSlot slot, Map variables) { + return template.content().slot(slot).map(source -> engine.render(source, variables)); + } + + private static String canonicalForm(NotificationContent content) { + return switch (content) { + case EmailContent email -> + "subject=" + + email.subject() + + "\u001ftext=" + + email.textBody() + + "\u001fhtml=" + + email.htmlBody().orElse(""); + case SmsContent sms -> "text=" + sms.text(); + case MobilePushContent push -> + "title=" + + push.title() + + "\u001fbody=" + + push.body() + + "\u001flink=" + + push.deepLink().map(URI::toString).orElse(""); + case WebPushContent webPush -> + "title=" + + webPush.title() + + "\u001fbody=" + + webPush.body() + + "\u001flink=" + + webPush.deepLink().map(URI::toString).orElse(""); + case InAppContent inApp -> + "title=" + + inApp.title() + + "\u001fbody=" + + inApp.body() + + "\u001fcategory=" + + inApp.category(); + }; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JacksonInboxContentCodec.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JacksonInboxContentCodec.java new file mode 100644 index 00000000..de60cca7 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JacksonInboxContentCodec.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import dev.caskeleton.application.notification.platform.api.content.InAppAction; +import dev.caskeleton.application.notification.platform.api.content.InAppContent; +import dev.caskeleton.application.notification.platform.inbox.InboxContentCodecPort; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import tools.jackson.core.type.TypeReference; + +/** + * Inbox content encoding. + * + *

Lives here rather than in the persistence adapter so that the persistence leaf needs no JSON + * library, and so the stored shape has exactly one owner. + */ +public final class JacksonInboxContentCodec implements InboxContentCodecPort { + + @Override + public String encode(InAppContent content) { + Objects.requireNonNull(content, "content"); + return NotificationJsonMapper.mapper() + .writeValueAsString( + Map.of( + "title", content.title(), + "body", content.body(), + "deepLink", content.deepLink().map(URI::toString).orElse(""), + "actions", + content.actions().stream() + .map( + action -> + Map.of( + "actionId", action.actionId(), + "label", action.label(), + "deepLink", action.deepLink().map(URI::toString).orElse(""))) + .toList())); + } + + @Override + public InAppContent decode(String payload, String category) { + Objects.requireNonNull(payload, "payload"); + Objects.requireNonNull(category, "category"); + Map fields = + NotificationJsonMapper.mapper() + .readValue(payload, new TypeReference>() {}); + + return new InAppContent( + String.valueOf(fields.getOrDefault("title", "")), + String.valueOf(fields.getOrDefault("body", "")), + optionalUri(fields.get("deepLink")), + actions(fields.get("actions")), + category); + } + + private static Optional optionalUri(Object value) { + String text = value == null ? "" : String.valueOf(value); + return text.isBlank() ? Optional.empty() : Optional.of(URI.create(text)); + } + + @SuppressWarnings("unchecked") + private static List actions(Object value) { + if (!(value instanceof List raw)) { + return List.of(); + } + return raw.stream() + .filter(Map.class::isInstance) + .map(entry -> (Map) entry) + .map( + entry -> + new InAppAction( + String.valueOf(entry.getOrDefault("actionId", "")), + String.valueOf(entry.getOrDefault("label", "")), + optionalUri(entry.get("deepLink")))) + .toList(); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JacksonNotificationVariablesCodec.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JacksonNotificationVariablesCodec.java new file mode 100644 index 00000000..5ea04a15 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JacksonNotificationVariablesCodec.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.NotificationValidationException; +import dev.caskeleton.application.notification.platform.dispatch.NotificationVariablesCodecPort; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; + +/** + * Canonical JSON encoding of template variables. + * + *

Keys are sorted before writing, so the stored payload and the request fingerprint derived from + * it stay stable across callers and across restarts. + * + *

Failures never carry the payload: a rejected variables map routinely contains the recovery + * code or the amount the notification is about. + */ +public final class JacksonNotificationVariablesCodec implements NotificationVariablesCodecPort { + + @Override + public String encode(Map variables) { + Objects.requireNonNull(variables, "variables"); + try { + return NotificationJsonMapper.mapper().writeValueAsString(new TreeMap<>(variables)); + } catch (JacksonException failure) { + throw rejection(); + } + } + + @Override + public Map decode(String payload) { + Objects.requireNonNull(payload, "payload"); + try { + return NotificationJsonMapper.mapper() + .readValue(payload, new TypeReference>() {}); + } catch (JacksonException failure) { + throw rejection(); + } + } + + private static NotificationValidationException rejection() { + return new NotificationValidationException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD)); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JacksonTemplateContentCodec.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JacksonTemplateContentCodec.java new file mode 100644 index 00000000..a3bac7bc --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JacksonTemplateContentCodec.java @@ -0,0 +1,34 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import dev.caskeleton.application.notification.platform.template.TemplateContentCodecPort; +import dev.caskeleton.application.notification.platform.template.TemplateContentDefinition; +import dev.caskeleton.application.notification.platform.template.TemplateSlot; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import tools.jackson.core.type.TypeReference; + +/** Template content encoding. Slot names are stored, so a renamed enum constant fails loudly. */ +public final class JacksonTemplateContentCodec implements TemplateContentCodecPort { + + @Override + public String encode(TemplateContentDefinition content) { + Objects.requireNonNull(content, "content"); + Map slots = new TreeMap<>(); + content.slots().forEach((slot, source) -> slots.put(slot.name(), source)); + return NotificationJsonMapper.mapper().writeValueAsString(slots); + } + + @Override + public TemplateContentDefinition decode(String payload) { + Objects.requireNonNull(payload, "payload"); + Map raw = + NotificationJsonMapper.mapper() + .readValue(payload, new TypeReference>() {}); + Map slots = new EnumMap<>(TemplateSlot.class); + raw.forEach((name, source) -> slots.put(TemplateSlot.valueOf(name), source)); + return new TemplateContentDefinition(slots); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JsonSchemaVariableValidator.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JsonSchemaVariableValidator.java new file mode 100644 index 00000000..46b2a550 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JsonSchemaVariableValidator.java @@ -0,0 +1,63 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import com.networknt.schema.Error; +import com.networknt.schema.Schema; +import com.networknt.schema.SchemaRegistry; +import com.networknt.schema.SpecificationVersion; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.TemplateVariableValidationException; +import dev.caskeleton.application.notification.platform.template.TemplateVariableValidator; +import dev.caskeleton.application.notification.platform.template.VariableSchema; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; +import tools.jackson.databind.JsonNode; + +/** + * JSON Schema 2020-12 validation of template variables. + * + *

Validation runs before any provider call, so a missing or mistyped variable is a fast, + * non-retryable rejection rather than a message the recipient receives with a blank in it. + * + *

Validator messages are deliberately dropped rather than attached to the exception. A message + * such as {@code $.recoveryCode: must be at least 6 characters} echoes the instance, and the + * instance is exactly the secret-classified value the redaction rules exist to keep out of logs. + */ +public final class JsonSchemaVariableValidator implements TemplateVariableValidator { + + private final SchemaRegistry registry = + SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_2020_12); + private final Map compiled = new ConcurrentHashMap<>(); + + @Override + public void validate(VariableSchema schema, Map variables) { + Objects.requireNonNull(schema, "schema"); + Objects.requireNonNull(variables, "variables"); + + for (String required : schema.requiredVariables()) { + if (variables.get(required) == null) { + throw rejection(); + } + } + + JsonNode instance = NotificationJsonMapper.mapper().valueToTree(new TreeMap<>(variables)); + List errors = compiledSchema(schema).validate(instance); + if (!errors.isEmpty()) { + throw rejection(); + } + } + + private Schema compiledSchema(VariableSchema schema) { + return compiled.computeIfAbsent(schema.schemaJson(), registry::getSchema); + } + + private static TemplateVariableValidationException rejection() { + return new TemplateVariableValidationException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.TEMPLATE_VARIABLES_INVALID, FailureCategory.TEMPLATE_FAILURE)); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationDigest.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationDigest.java new file mode 100644 index 00000000..df06a261 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationDigest.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** SHA-256 helper shared by rendering, fingerprinting and payload protection. */ +public final class NotificationDigest { + + private NotificationDigest() {} + + /** Hex SHA-256 of a UTF-8 string. */ + public static String hex(String value) { + return hex(value.getBytes(StandardCharsets.UTF_8)); + } + + /** Hex SHA-256 of raw bytes. */ + public static String hex(byte[] value) { + return HexFormat.of().formatHex(sha256(value)); + } + + /** Raw SHA-256 of bytes. */ + public static byte[] sha256(byte[] value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is required by the Java platform", impossible); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationJsonMapper.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationJsonMapper.java new file mode 100644 index 00000000..84940d4c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationJsonMapper.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.json.JsonMapper; + +/** + * Shared JSON mapper for the notification adapter. + * + *

Map entries are written in key order. Without that, the canonical variables payload — and the + * request fingerprint computed from it — would depend on which map implementation the caller + * happened to pass, which is exactly the kind of instability idempotency cannot tolerate. + */ +public final class NotificationJsonMapper { + + private static final ObjectMapper MAPPER = + JsonMapper.builder().enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).build(); + + private NotificationJsonMapper() {} + + /** The shared, deterministically configured mapper. */ + public static ObjectMapper mapper() { + return MAPPER; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationTemplateEngine.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationTemplateEngine.java new file mode 100644 index 00000000..1d27132a --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationTemplateEngine.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import java.util.Map; + +/** + * Renders one template slot. + * + *

The seam exists so the renderer's contract — validate first, assemble the channel content, + * digest the result — is shared by every engine, and only the substitution differs. Duplicating the + * renderer per engine is how two implementations end up computing different digests for the same + * template, which silently breaks the retry equality the digest exists to prove. + */ +@FunctionalInterface +public interface NotificationTemplateEngine { + + /** + * Render one slot. + * + * @throws dev.caskeleton.application.notification.platform.api.error.TemplateRenderingException + * when a referenced variable is absent — never rendered as an empty string + */ + String render(String source, Map variables); +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/PlaceholderTemplateEngine.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/PlaceholderTemplateEngine.java new file mode 100644 index 00000000..192facd0 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/PlaceholderTemplateEngine.java @@ -0,0 +1,47 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.TemplateRenderingException; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Deterministic {@code {name}} placeholder substitution. + * + *

Deliberately not a general expression language. A renderer that can evaluate arbitrary + * expressions over caller-supplied variables is a server-side template injection surface, and + * notification variables come from application input by definition. + * + *

An unresolved placeholder fails rendering instead of rendering an empty string: a password + * reset mail that says "your code is " is worse than one that was never sent. + */ +public final class PlaceholderTemplateEngine implements NotificationTemplateEngine { + + private static final Pattern PLACEHOLDER = Pattern.compile("\\{([a-zA-Z0-9_.-]{1,64})\\}"); + + /** Render one slot. */ + @Override + public String render(String source, Map variables) { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(variables, "variables"); + + Matcher matcher = PLACEHOLDER.matcher(source); + StringBuilder rendered = new StringBuilder(source.length()); + while (matcher.find()) { + Object value = variables.get(matcher.group(1)); + if (value == null) { + throw new TemplateRenderingException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.TEMPLATE_RENDERING_FAILED, + FailureCategory.TEMPLATE_FAILURE)); + } + matcher.appendReplacement(rendered, Matcher.quoteReplacement(String.valueOf(value))); + } + matcher.appendTail(rendered); + return rendered.toString(); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/Sha256MessageDigestAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/Sha256MessageDigestAdapter.java new file mode 100644 index 00000000..15a9409b --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/Sha256MessageDigestAdapter.java @@ -0,0 +1,12 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import dev.caskeleton.application.notification.platform.dispatch.MessageDigestPort; + +/** Hashing adapter for the canonical request fingerprint. */ +public final class Sha256MessageDigestAdapter implements MessageDigestPort { + + @Override + public byte[] sha256(byte[] input) { + return NotificationDigest.sha256(input); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafNotificationRenderer.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafNotificationRenderer.java new file mode 100644 index 00000000..b7e6a77d --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafNotificationRenderer.java @@ -0,0 +1,50 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.template.NotificationTemplateRenderer; +import dev.caskeleton.application.notification.platform.template.RenderCommand; +import dev.caskeleton.application.notification.platform.template.RenderedNotificationContent; +import dev.caskeleton.application.notification.platform.template.TemplateRegistry; +import dev.caskeleton.application.notification.platform.template.TemplateVariableValidator; +import java.util.Objects; + +/** + * Reference renderer backed by Thymeleaf. + * + *

Composition rather than a parallel implementation: validation order, channel content assembly + * and the content digest are the renderer's contract, and the engine is the only thing that + * differs. A second hand-written renderer is how two of them end up computing different digests for + * the same template — which quietly breaks the retry-sent-the-same-content guarantee the digest + * exists for. + * + *

No Thymeleaf type appears on this class's signature, so the application-side template contract + * stays engine-free and a deployment can swap engines without recompiling anything above the + * adapter. + */ +public final class ThymeleafNotificationRenderer implements NotificationTemplateRenderer { + + private final CanonicalNotificationRenderer delegate; + + public ThymeleafNotificationRenderer( + Channel channel, + TemplateRegistry templates, + TemplateVariableValidator validator, + ThymeleafStringTemplateEngine engine) { + this.delegate = + new CanonicalNotificationRenderer( + Objects.requireNonNull(channel, "channel"), + Objects.requireNonNull(templates, "templates"), + Objects.requireNonNull(validator, "validator"), + Objects.requireNonNull(engine, "engine")); + } + + @Override + public Channel channel() { + return delegate.channel(); + } + + @Override + public RenderedNotificationContent render(RenderCommand command) { + return delegate.render(command); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafStringTemplateEngine.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafStringTemplateEngine.java new file mode 100644 index 00000000..e119b706 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafStringTemplateEngine.java @@ -0,0 +1,108 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.TemplateRenderingException; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; +import org.thymeleaf.templatemode.TemplateMode; +import org.thymeleaf.templateresolver.StringTemplateResolver; + +/** + * Thymeleaf as the reference HTML engine. + * + *

Thymeleaf earns its place on one axis the placeholder engine cannot cover: it escapes by + * default in HTML mode. A notification variable is application input, and {@code } rendering a name containing markup into an HTML email is the difference between an + * escaped string and an injected one. + * + *

Two things are deliberately turned off. + * + *

Template cache. Sources come from the template registry, not from the classpath, and a + * cache keyed by source text on a per-tenant registry is an unbounded map keyed by attacker-visible + * content. Rendering is not the bottleneck a notification platform has. + * + *

Caller input never becomes template source. Thymeleaf does evaluate expressions — OGNL + * is its default engine and is on this classpath. What keeps that from being a server-side template + * injection surface is the direction of the data: the source comes from the operator-owned + * template registry, and caller-supplied variables only ever enter as context values, which are + * data to the evaluator rather than program text. Rendering a caller-supplied string as a template + * would break that and must not be added. The plain {@code org.thymeleaf:thymeleaf} artifact is + * used rather than the Spring starter so no SpringEL evaluation context, bean resolution or view + * resolver reaches an outbound adapter that only ever renders strings. + */ +public final class ThymeleafStringTemplateEngine implements NotificationTemplateEngine { + + /** Root identifier of a {@code ${...}} or {@code *{...}} expression. */ + private static final Pattern EXPRESSION_ROOT = + Pattern.compile("[$*]\\{\\s*([a-zA-Z_][a-zA-Z0-9_]*)"); + + private final TemplateEngine engine; + + /** HTML-escaping engine, which is the safe default for email bodies. */ + public ThymeleafStringTemplateEngine() { + this(TemplateMode.HTML); + } + + /** + * @param mode {@link TemplateMode#HTML} to escape, {@link TemplateMode#TEXT} for plain-text slots + */ + public ThymeleafStringTemplateEngine(TemplateMode mode) { + Objects.requireNonNull(mode, "mode"); + StringTemplateResolver resolver = new StringTemplateResolver(); + resolver.setTemplateMode(mode); + resolver.setCacheable(false); + TemplateEngine created = new TemplateEngine(); + created.setTemplateResolver(resolver); + this.engine = created; + } + + @Override + public String render(String source, Map variables) { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(variables, "variables"); + requireEveryReferencedVariable(source, variables); + + Context context = new Context(); + variables.forEach(context::setVariable); + try { + return engine.process(source, context); + } catch (RuntimeException failure) { + // The message is dropped on purpose. Thymeleaf reports the offending expression, and a + // template expression contains the variable it failed on — which for this platform is a + // one-time code or a recipient name. + throw new TemplateRenderingException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.TEMPLATE_RENDERING_FAILED, FailureCategory.TEMPLATE_FAILURE)); + } + } + + /** + * Fail on an absent variable instead of rendering it away. + * + *

Thymeleaf resolves a missing variable to null and writes an empty string. That default is + * right for a web page with an optional section and wrong for a notification: "your code is " + * reaches the recipient, looks delivered on every metric, and is worse than a notification that + * was never sent. Checked here rather than after rendering, because an empty rendered slot is + * indistinguishable from a legitimately empty one. + * + *

Only the root of each expression is required — {@code ${user.name}} needs {@code user} — + * since anything deeper is the schema validator's job. + */ + private static void requireEveryReferencedVariable(String source, Map variables) { + Matcher references = EXPRESSION_ROOT.matcher(source); + while (references.find()) { + if (!variables.containsKey(references.group(1))) { + throw new TemplateRenderingException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.TEMPLATE_RENDERING_FAILED, + FailureCategory.TEMPLATE_FAILURE)); + } + } + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformSettingsTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformSettingsTest.java new file mode 100644 index 00000000..48d6fe39 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformSettingsTest.java @@ -0,0 +1,152 @@ +package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class NotificationPlatformSettingsTest { + + @Test + void productionWebPushWithoutVapidKeyFailsStartup() { + assertThatThrownBy(() -> properties(webPushWithoutVapid())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("VAPID"); + } + + @Test + void apnsWithoutTopicFailsStartup() { + assertThatThrownBy(() -> properties(apnsWithoutTopic())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("topic"); + } + + @Test + void aCallbackCapableProviderRequiresASigningSecret() { + assertThatThrownBy(() -> properties(twilioWithoutSigningSecret())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("signing secret"); + } + + @Test + void aDisabledProfileIsNotValidated() { + assertThatCode(() -> properties(disabled(webPushWithoutVapid()))).doesNotThrowAnyException(); + } + + @Test + void ambiguousFallbackCannotBeEnabled() { + assertThatThrownBy( + () -> + new NotificationPlatformSettings.Dispatch( + 100, + Duration.ofSeconds(30), + Duration.ofMillis(250), + 128, + 3, + Duration.ofHours(24), + true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("allow-ambiguous-fallback"); + } + + @Test + void anUnboundedClaimBatchIsRefused() { + assertThatThrownBy( + () -> + new NotificationPlatformSettings.Dispatch( + 100_000, + Duration.ofSeconds(30), + Duration.ofMillis(250), + 128, + 3, + Duration.ofHours(24), + false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("claim-batch-size"); + } + + @Test + void aLeaseShorterThanThePollIntervalIsRefused() { + assertThatThrownBy( + () -> + new NotificationPlatformSettings.Dispatch( + 100, + Duration.ofMillis(100), + Duration.ofSeconds(1), + 128, + 3, + Duration.ofHours(24), + false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("lease-duration"); + } + + @Test + void defaultsAreUsableAndConservative() { + var defaults = NotificationPlatformSettings.Dispatch.defaults(); + + assertThat(defaults.allowAmbiguousFallback()).isFalse(); + assertThat(defaults.claimBatchSize()).isPositive(); + assertThat(NotificationPlatformSettings.Callbacks.defaults().enabled()).isFalse(); + } + + private static NotificationPlatformSettings properties( + NotificationPlatformSettings.Provider provider) { + return new NotificationPlatformSettings( + true, + NotificationPlatformSettings.Dispatch.defaults(), + NotificationPlatformSettings.Callbacks.defaults(), + Map.of("profile", provider)); + } + + private static NotificationPlatformSettings.Provider webPushWithoutVapid() { + return new NotificationPlatformSettings.Provider( + "WEB_PUSH", + true, + "PRODUCTION", + "webpush-main", + null, + null, + null, + Duration.ofSeconds(3), + 8, + 20); + } + + private static NotificationPlatformSettings.Provider apnsWithoutTopic() { + return new NotificationPlatformSettings.Provider( + "APNS", true, "PRODUCTION", "apns-main", null, null, null, Duration.ofSeconds(3), 8, 20); + } + + private static NotificationPlatformSettings.Provider twilioWithoutSigningSecret() { + return new NotificationPlatformSettings.Provider( + "TWILIO", + true, + "PRODUCTION", + "twilio-main", + null, + null, + null, + Duration.ofSeconds(3), + 8, + 20); + } + + private static NotificationPlatformSettings.Provider disabled( + NotificationPlatformSettings.Provider provider) { + return new NotificationPlatformSettings.Provider( + provider.type(), + false, + provider.environment(), + provider.credentialProfile(), + provider.topic(), + provider.vapidPublicKey(), + provider.callbackSigningSecretRef(), + provider.timeout(), + provider.maxConcurrency(), + provider.ratePerSecond()); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationReleaseGateTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationReleaseGateTest.java new file mode 100644 index 00000000..5f8df14b --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationReleaseGateTest.java @@ -0,0 +1,63 @@ +package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +/** + * Release gate. + * + *

The documentation is part of the contract, not a nicety: an operator handling an ambiguous + * attempt at 3am needs the runbook to exist, and a support matrix that quietly starts promising + * guaranteed delivery is a defect even though no code changed. + */ +class NotificationReleaseGateTest { + + private static final Path DOCS = Path.of("..", "..", "..", "..", "docs", "notification"); + + @Test + void requiredDocumentationAndAdrsExist() { + assertThat(DOCS.resolve("delivery-evidence.md")).exists(); + assertThat(DOCS.resolve("callback-reconciliation.md")).exists(); + assertThat(DOCS.resolve("support-matrix.md")).exists(); + assertThat(DOCS.resolve("security-privacy.md")).exists(); + assertThat(DOCS.resolve("operations.md")).exists(); + assertThat(DOCS.resolve("provider-runbooks.md")).exists(); + assertThat(DOCS.resolve("configuration-reference.md")).exists(); + assertThat(DOCS.resolve("migration-guide.md")).exists(); + assertThat(DOCS.resolve("module-mapping.md")).exists(); + assertThat(DOCS.resolve("adr/NOTIF-ADR-001-durable-acceptance.md")).exists(); + assertThat(DOCS.resolve("adr/NOTIF-ADR-002-event-ledger-projection.md")).exists(); + assertThat(DOCS.resolve("adr/NOTIF-ADR-003-ambiguous-submission.md")).exists(); + assertThat(DOCS.resolve("adr/NOTIF-ADR-004-fcm-fid-primary.md")).exists(); + } + + @Test + void supportMatrixDoesNotClaimGuaranteedDelivery() throws IOException { + String text = Files.readString(DOCS.resolve("support-matrix.md")); + + // The terms must appear, but only under the refusal heading. Asserting they are absent + // altogether would be the wrong test: a matrix that never mentions guaranteed delivery leaves + // the reader to assume it, which is exactly the assumption this document exists to remove. + int refusalHeading = text.indexOf("## Not supported"); + assertThat(refusalHeading).as("the matrix must state what it refuses to claim").isPositive(); + + String claims = text.substring(0, refusalHeading); + String refusals = text.substring(refusalHeading); + + assertThat(claims).doesNotContain("guaranteed delivery", "guaranteed read", "exactly-once"); + assertThat(refusals).contains("guaranteed delivery", "guaranteed read", "exactly-once"); + assertThat(text).contains("PROVIDER_ACCEPTED", "AMBIGUOUS", "FCM_FID"); + } + + @Test + void theEvidenceDocumentStatesTheForbiddenPromotions() throws IOException { + String text = Files.readString(DOCS.resolve("support-matrix.md")); + + assertThat(text).contains("is not `DEVICE_DELIVERED`"); + assertThat(text).contains("is not `DELIVERED`"); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRegistryTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRegistryTest.java new file mode 100644 index 00000000..46a8ac0f --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRegistryTest.java @@ -0,0 +1,192 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.api.error.ProviderUnavailableException; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.observation.NotificationAuditEvent; +import dev.caskeleton.application.notification.platform.observation.NotificationAuditPort; +import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; +import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities; +import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot; +import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import org.junit.jupiter.api.Test; + +class ProviderRuntimeRegistryTest { + + private static final ProviderProfileId PROFILE = new ProviderProfileId("apns-main"); + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-08-10T00:00:00Z"), ZoneOffset.UTC); + + @Test + void authenticationFailureRejectsNewAttemptsWithoutConsumingPermit() { + var runtime = runtime(1); + runtime.markAuthenticationFailed("INVALID_CREDENTIAL"); + + assertThatThrownBy(runtime::acquireAttempt).isInstanceOf(ProviderUnavailableException.class); + assertThat(runtime.activeAttempts()).isZero(); + assertThat(runtime.state()).isEqualTo(ProviderRuntimeState.AUTHENTICATION_FAILED); + } + + @Test + void replacementKeepsOldRuntimeDrainingUntilItsAttemptsFinish() { + var registry = new ProviderRuntimeRegistry(); + var first = runtime(1); + registry.register(first); + + var permit = first.acquireAttempt(); + registry.replace(runtime(2)); + + assertThat(registry.current(PROFILE).generation()).isEqualTo(2); + assertThat(first.state()).isEqualTo(ProviderRuntimeState.DRAINING); + assertThat(registry.drainingGenerations(PROFILE)).hasSize(1); + + permit.close(); + assertThat(registry.drainingGenerations(PROFILE)).isEmpty(); + } + + @Test + void newAttemptUsesNewGenerationWhileOldAttemptDrains() { + var registry = new ProviderRuntimeRegistry(); + registry.register(runtime(1)); + var oldPermit = registry.current(PROFILE).acquireAttempt(); + + rotator(registry, candidate -> true).rotate(PROFILE, runtime(2)); + var newPermit = registry.current(PROFILE).acquireAttempt(); + + assertThat(oldPermit.generation()).isEqualTo(1); + assertThat(newPermit.generation()).isEqualTo(2); + oldPermit.close(); + newPermit.close(); + assertThat(registry.drainingGenerations(PROFILE)).isEmpty(); + } + + @Test + void failedNewCredentialKeepsOldRuntimeActive() { + var registry = new ProviderRuntimeRegistry(); + registry.register(runtime(1)); + + assertThatThrownBy(() -> rotator(registry, candidate -> false).rotate(PROFILE, runtime(2))) + .isInstanceOf(CredentialValidationException.class); + assertThat(registry.current(PROFILE).generation()).isEqualTo(1); + assertThat(registry.current(PROFILE).state()).isEqualTo(ProviderRuntimeState.HEALTHY); + } + + @Test + void concurrencyLimitFailsFastRatherThanQueueing() { + var runtime = runtime(1); + var permit = runtime.acquireAttempt(); + + assertThatThrownBy(runtime::acquireAttempt).isInstanceOf(ProviderUnavailableException.class); + permit.close(); + runtime.acquireAttempt().close(); + } + + @Test + void rotationAuditRecordsGenerationButNoCredentialMaterial() { + var registry = new ProviderRuntimeRegistry(); + registry.register(runtime(1)); + var audit = new RecordingAudit(); + + new ProviderRuntimeRotator( + registry, + candidate -> true, + new RuntimeDrainCoordinator(Duration.ofMillis(1)), + audit, + CLOCK, + Duration.ofMillis(1)) + .rotate(PROFILE, runtime(2)); + + assertThat(audit.events).hasSize(1); + assertThat(audit.events.get(0).boundedAttributes()).containsEntry("generation", "2"); + assertThat(audit.events.get(0).boundedAttributes().values()) + .noneMatch(value -> value.contains("key")); + } + + private static ProviderRuntimeRotator rotator( + ProviderRuntimeRegistry registry, CredentialProbe probe) { + return new ProviderRuntimeRotator( + registry, + probe, + new RuntimeDrainCoordinator(Duration.ofMillis(1)), + new RecordingAudit(), + CLOCK, + Duration.ofMillis(1)); + } + + private static ProviderRuntime runtime(long generation) { + return new ProviderRuntime( + new ProviderProfileSnapshot( + PROFILE, + new ProviderId("apns"), + Channel.PUSH, + "PRODUCTION", + generation, + new ProviderCapabilities( + false, + false, + false, + false, + false, + false, + false, + true, + 1, + 4096L, + Duration.ofHours(1)), + Map.of("topic", "com.example.app")), + new StubAdapter(), + new ProviderAttemptLimiter(1, 100, CLOCK)); + } + + /** Adapter that is never actually invoked by these runtime tests. */ + private static final class StubAdapter implements NotificationProviderAdapter { + + @Override + public ProviderId providerId() { + return new ProviderId("apns"); + } + + @Override + public Set channels() { + return Set.of(Channel.PUSH); + } + + @Override + public ProviderCapabilities capabilities() { + return new ProviderCapabilities( + false, false, false, false, false, false, false, true, 1, 4096L, Duration.ofHours(1)); + } + + @Override + public CompletionStage submit(ProviderSubmission submission) { + return CompletableFuture.failedFuture(new UnsupportedOperationException()); + } + } + + /** Audit port that keeps what it was told. */ + private static final class RecordingAudit implements NotificationAuditPort { + + private final List events = new ArrayList<>(); + + @Override + public void record(NotificationAuditEvent event) { + events.add(event); + } + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRotationTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRotationTest.java new file mode 100644 index 00000000..6fe877ab --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRotationTest.java @@ -0,0 +1,96 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.notification.platform.security.CredentialGeneration; +import dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager; +import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import org.junit.jupiter.api.Test; + +/** + * Credential rotation as a numbered, auditable generation change. + * + *

The runtime-swap and drain half of this behaviour is certified by {@code + * ProviderRuntimeRegistryTest}; what is certified here is the bookkeeping that decides which + * generation a rotation is allowed to become, and the two rotations this manager must refuse rather + * than perform badly. + */ +class ProviderRuntimeRotationTest { + + private static final ProviderProfileId PROFILE = new ProviderProfileId("ses-primary"); + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-08-14T00:00:00Z"), ZoneOffset.UTC); + + @Test + void activationRecordsTheGenerationAndItsKeyIdButNeverTheMaterial() { + var manager = new ProviderCredentialManager(SecurityFixtures.keys(), CLOCK); + + var activated = manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1")); + + assertThat(activated.generation()).isEqualTo(1); + assertThat(activated.activatedAt()).contains(CLOCK.instant()); + assertThat(activated.auditForm()).isEqualTo("ses-primary#1/cred-1"); + // The audit form is the only string this type renders; it names the key, never its bytes. + assertThat(activated.auditForm()).doesNotContain("DDDD"); + } + + @Test + void aGenerationThatDoesNotSupersedeTheActiveOneIsRefused() { + var manager = new ProviderCredentialManager(SecurityFixtures.keys(), CLOCK); + manager.activate(CredentialGeneration.candidate(PROFILE, 2, "cred-1")); + + // Re-using or lowering the number would make an attempt record ambiguous about which credential + // signed it, which is the first question an incident asks. + assertThatThrownBy(() -> manager.activate(CredentialGeneration.candidate(PROFILE, 2, "cred-1"))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1"))) + .isInstanceOf(IllegalArgumentException.class); + assertThat(manager.current(PROFILE).orElseThrow().generation()).isEqualTo(2); + assertThat(manager.nextGenerationNumber(PROFILE)).isEqualTo(3); + } + + @Test + void anUnresolvableKeyIdFailsTheRotationRatherThanTheFirstNotification() { + var manager = new ProviderCredentialManager(SecurityFixtures.keys(), CLOCK); + + assertThatThrownBy(() -> manager.activate(CredentialGeneration.candidate(PROFILE, 1, "absent"))) + .isInstanceOf(IllegalStateException.class); + assertThat(manager.current(PROFILE)).isEmpty(); + } + + @Test + void aKeyIssuedForAnotherPurposeCannotBecomeAProviderCredential() { + var manager = new ProviderCredentialManager(SecurityFixtures.keys(), CLOCK); + + assertThatThrownBy(() -> manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cb-1"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void contactEncryptionAndVapidRotationAreRefusedWithTheMigrationTheyActuallyNeed() { + assertThatThrownBy( + () -> ProviderCredentialManager.rejectManagedRotation(SecretPurpose.CONTACT_ENCRYPTION)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("re-encryption"); + assertThatThrownBy( + () -> ProviderCredentialManager.rejectManagedRotation(SecretPurpose.VAPID_SIGNING)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("subscription migration"); + } + + @Test + void materialIsResolvedPerCallSoARotationIsNotOutlivedByACachedCredential() { + var manager = new ProviderCredentialManager(SecurityFixtures.keys(), CLOCK); + var first = manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1")); + + assertThat(manager.material(first).keyId()).isEqualTo("cred-1"); + assertThat(manager.material(first).purpose()).isEqualTo(SecretPurpose.PROVIDER_CREDENTIAL); + assertThat(manager.material(first).toString()).contains("redacted"); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsNotificationProviderAdapterTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsNotificationProviderAdapterTest.java new file mode 100644 index 00000000..54cc57c9 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsNotificationProviderAdapterTest.java @@ -0,0 +1,149 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.apns; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector; +import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderAdapterContract; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFaultHarness; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures; +import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.ProviderConfigurationException; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.contact.ApnsDeviceToken; +import dev.caskeleton.application.notification.platform.contact.ApnsEnvironment; +import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class ApnsNotificationProviderAdapterTest extends ProviderAdapterContract { + + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-08-14T00:00:00Z"), ZoneOffset.UTC); + + private final ProviderFaultHarness harness = new ProviderFaultHarness(); + private final ContactPointProtector protector = + new AesGcmContactPointProtector(SecurityFixtures.keys()); + + @AfterEach + void stopHarness() { + harness.close(); + } + + private ApnsProviderProperties properties(ApnsEnvironment environment) { + return new ApnsProviderProperties( + harness.baseUri(), "com.example.app", environment, Set.of("alert"), Duration.ofSeconds(3)); + } + + private NotificationProviderAdapter adapter(ApnsEnvironment environment) { + return new ApnsNotificationProviderAdapter( + new JdkNotificationHttpGateway(Duration.ofSeconds(2)), + new ApnsRequestMapper(properties(environment), CLOCK), + new ApnsFailureClassifier(), + protector, + () -> "bearer test-token", + properties(environment)); + } + + @Override + protected NotificationProviderAdapter adapter() { + return adapter(ApnsEnvironment.PRODUCTION); + } + + @Override + protected ProviderFaultHarness harness() { + return harness; + } + + @Override + protected ProviderSubmission submission() { + return submission(ApnsEnvironment.PRODUCTION); + } + + private ProviderSubmission submission(ApnsEnvironment environment) { + return ProviderFixtures.submission( + ProviderFixtures.profile("apns-main", "apns", Channel.PUSH), + Channel.PUSH, + ProviderFixtures.push(), + protector, + new ApnsDeviceToken("device-token-value", environment), + Optional.of(CLOCK.instant().plus(Duration.ofHours(1)))); + } + + @Override + protected String successBody() { + return ""; + } + + @Test + void http200IsProviderAcceptedNotDelivered() { + harness.respondWith(200, "", Map.of("apns-id", "apns-request-1")); + + var result = adapter().submit(submission()).toCompletableFuture().join(); + + assertThat(result.providerRequestId()).contains("apns-request-1"); + assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED); + assertThat(result.deliveryOutcome()) + .isEqualTo( + dev.caskeleton.application.notification.platform.api.delivery.DeliveryOutcome.UNKNOWN); + } + + @Test + void sandboxTokenCannotUseProductionProfile() { + harness.respondWith(200, "", Map.of()); + + assertThatThrownBy( + () -> + adapter(ApnsEnvironment.PRODUCTION) + .submit(submission(ApnsEnvironment.SANDBOX)) + .toCompletableFuture() + .join()) + .isInstanceOf(ProviderConfigurationException.class); + } + + @Test + void unregisteredTokenIsAnInvalidRecipient() { + harness.respondWith(410, "{\"reason\":\"Unregistered\"}", Map.of()); + + var result = adapter().submit(submission()).toCompletableFuture().join(); + + assertThat(result.failure().orElseThrow().category()) + .isEqualTo(FailureCategory.INVALID_RECIPIENT); + assertThat(result.failure().orElseThrow().retryable()).isFalse(); + } + + @Test + void requiredHeadersAreSentAndCarryNoDeviceTokenInTheQuery() { + harness.respondWith(200, "", Map.of()); + + adapter().submit(submission()).toCompletableFuture().join(); + + var recorded = harness.received().get(0); + assertThat(recorded.header("apns-topic")).contains("com.example.app"); + assertThat(recorded.header("apns-push-type")).contains("alert"); + assertThat(recorded.header("apns-expiration")).isPresent(); + assertThat(recorded.uri().getQuery()).isNull(); + } + + @Test + void anExpiredProviderTokenBecomesAnAuthenticationFailureNotAMessageRetry() { + harness.respondWith(403, "{\"reason\":\"ExpiredProviderToken\"}", Map.of()); + + var result = adapter().submit(submission()).toCompletableFuture().join(); + + assertThat(result.failure().orElseThrow().category()).isEqualTo(FailureCategory.AUTHENTICATION); + assertThat(result.failure().orElseThrow().retryable()).isFalse(); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchAdapterTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchAdapterTest.java new file mode 100644 index 00000000..0b08c721 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchAdapterTest.java @@ -0,0 +1,189 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector; +import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures; +import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation; +import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.contact.FcmInstallationId; +import dev.caskeleton.application.notification.platform.contact.LegacyFcmRegistrationToken; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import java.net.URI; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; + +class FcmBatchAdapterTest { + + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-08-14T00:00:00Z"), ZoneOffset.UTC); + + private final ContactPointProtector protector = + new AesGcmContactPointProtector(SecurityFixtures.keys()); + private final FcmProviderProperties properties = + new FcmProviderProperties( + URI.create("https://fcm.example"), + "example-prod", + "mobile-main", + 500, + Duration.ofHours(4), + Duration.ofSeconds(3)); + + @Test + void mapsPartialBatchResultToEachRecipientAttempt() { + var gateway = gatewayWith(true, false, true, false, true); + var results = coordinator(gateway).submit(submissions(5)).toCompletableFuture().join(); + + assertThat(results).hasSize(5); + assertThat(results.get(0).confirmation()).isEqualTo(AttemptConfirmation.CONFIRMED); + assertThat(results.get(0).evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED); + assertThat(results.get(1).confirmation()).isEqualTo(AttemptConfirmation.REJECTED); + assertThat(results.get(2).confirmation()).isEqualTo(AttemptConfirmation.CONFIRMED); + assertThat(results.get(4).confirmation()).isEqualTo(AttemptConfirmation.CONFIRMED); + } + + @Test + void rejectsBatchAboveTheProviderMaximum() { + assertThatThrownBy( + () -> + coordinator(gatewayWith(true)) + .submit(submissions(501)) + .toCompletableFuture() + .join()) + .isInstanceOf(ProviderPayloadLimitException.class); + } + + @Test + void fidAndLegacyTokenUseDistinctWireTargetKinds() { + var mapper = new FcmTargetMapper(); + + assertThat(mapper.map(new FcmInstallationId("fid-1")).kind()).isEqualTo("FID"); + assertThat(mapper.map(new LegacyFcmRegistrationToken("token-1")).kind()) + .isEqualTo("LEGACY_TOKEN"); + } + + @Test + void fidAndLegacyTokenLandInDifferentRequestFields() { + var messageMapper = new FcmMessageMapper(properties, CLOCK); + var targetMapper = new FcmTargetMapper(); + + var fid = + messageMapper.map( + submission(new FcmInstallationId("fid-1")), + targetMapper.map(new FcmInstallationId("fid-1"))); + var legacy = + messageMapper.map( + submission(new LegacyFcmRegistrationToken("token-1")), + targetMapper.map(new LegacyFcmRegistrationToken("token-1"))); + + assertThat(message(fid)).containsKey("installation_id"); + assertThat(message(legacy)).containsKey("token"); + } + + @Test + void ttlIsCappedByTheDeliveryExpiry() { + var mapper = new FcmMessageMapper(properties, CLOCK); + var submission = + ProviderFixtures.submission( + ProviderFixtures.profile("fcm-main", "fcm", Channel.PUSH), + Channel.PUSH, + ProviderFixtures.push(), + protector, + new FcmInstallationId("fid-1"), + Optional.of(CLOCK.instant().plusSeconds(90))); + + assertThat(mapper.ttl(submission)).isEqualTo(Duration.ofSeconds(90)); + } + + @Test + void ttlFallsBackToTheProviderMaximumWhenNoExpiryIsSet() { + var mapper = new FcmMessageMapper(properties, CLOCK); + + assertThat(mapper.ttl(submission(new FcmInstallationId("fid-1")))) + .isEqualTo(properties.maxTtl()); + } + + @Test + void unregisteredIsInvalidRecipientAndNeverRetried() { + var classifier = new FcmFailureClassifier(); + + var failure = classifier.failure("UNREGISTERED"); + + assertThat(failure.category()).isEqualTo(FailureCategory.INVALID_RECIPIENT); + assertThat(failure.retryable()).isFalse(); + assertThat(classifier.invalidatesContactPoint("UNREGISTERED")).isTrue(); + assertThat(classifier.invalidatesContactPoint("UNAVAILABLE")).isFalse(); + } + + @Test + void quotaAndUnavailableAreRetryableButAuthIsNot() { + var classifier = new FcmFailureClassifier(); + + assertThat(classifier.failure("QUOTA_EXCEEDED").category()) + .isEqualTo(FailureCategory.THROTTLED); + assertThat(classifier.failure("UNAVAILABLE").retryable()).isTrue(); + assertThat(classifier.failure("THIRD_PARTY_AUTH_ERROR").category()) + .isEqualTo(FailureCategory.AUTHENTICATION); + assertThat(classifier.failure("THIRD_PARTY_AUTH_ERROR").retryable()).isFalse(); + } + + @SuppressWarnings("unchecked") + private static Map message(Map request) { + return (Map) request.get("message"); + } + + private FcmBatchCoordinator coordinator(FcmGateway gateway) { + return new FcmBatchCoordinator( + gateway, + new FcmMessageMapper(properties, CLOCK), + new FcmTargetMapper(), + new FcmFailureClassifier(), + protector, + properties); + } + + private static FcmGateway gatewayWith(boolean... successes) { + return messages -> { + List items = new ArrayList<>(messages.size()); + for (int index = 0; index < messages.size(); index++) { + boolean success = index < successes.length ? successes[index] : true; + items.add( + success + ? FcmBatchResult.Item.success("message-" + index) + : FcmBatchResult.Item.failure("UNREGISTERED")); + } + return new FcmBatchResult(items); + }; + } + + private List submissions(int count) { + return IntStream.range(0, count) + .mapToObj(index -> submission(new FcmInstallationId("fid-" + index))) + .toList(); + } + + private ProviderSubmission submission( + dev.caskeleton.application.notification.platform.contact.ContactPointValue target) { + return ProviderFixtures.submission( + ProviderFixtures.profile("fcm-main", "fcm", Channel.PUSH), + Channel.PUSH, + ProviderFixtures.push(), + protector, + target, + Optional.empty()); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapterTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapterTest.java new file mode 100644 index 00000000..2abb0e53 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapterTest.java @@ -0,0 +1,104 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector; +import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderAdapterContract; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFaultHarness; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures; +import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.contact.EmailAddress; +import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class SesNotificationProviderAdapterTest extends ProviderAdapterContract { + + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-08-14T00:00:00Z"), ZoneOffset.UTC); + + private final ProviderFaultHarness harness = new ProviderFaultHarness(); + private final ContactPointProtector protector = + new AesGcmContactPointProtector(SecurityFixtures.keys()); + + @AfterEach + void stopHarness() { + harness.close(); + } + + @Override + protected NotificationProviderAdapter adapter() { + var properties = + new SesProviderProperties( + harness.baseUri(), + "ap-northeast-2", + "transactional@example.com", + Optional.empty(), + Duration.ofSeconds(3)); + return new SesNotificationProviderAdapter( + new JdkNotificationHttpGateway(Duration.ofSeconds(2)), + new SesRequestMapper(properties, new AwsSignatureV4Signer()), + new SesFailureClassifier(), + protector, + SecurityFixtures.keys(), + "AKIAEXAMPLE", + CLOCK); + } + + @Override + protected ProviderFaultHarness harness() { + return harness; + } + + @Override + protected ProviderSubmission submission() { + return ProviderFixtures.submission( + ProviderFixtures.profile("ses-primary", "ses", Channel.EMAIL), + Channel.EMAIL, + ProviderFixtures.email(), + protector, + EmailAddress.parse(ProviderFixtures.SECRET_EMAIL), + Optional.of(CLOCK.instant().plus(Duration.ofHours(1)))); + } + + @Override + protected String successBody() { + return "{\"MessageId\":\"ses-message-1\"}"; + } + + @Test + void messageIdIsAcceptedEvidenceOnly() { + harness.respondWith(200, successBody(), Map.of()); + + var result = adapter().submit(submission()).toCompletableFuture().join(); + + assertThat(result.providerRequestId()).contains("ses-message-1"); + assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED); + assertThat(result.deliveryOutcome()) + .isEqualTo( + dev.caskeleton.application.notification.platform.api.delivery.DeliveryOutcome.UNKNOWN); + } + + @Test + void everyRequestIsSignedAndCarriesNoRawRecipientInTheUrl() { + harness.respondWith(200, successBody(), Map.of()); + + adapter().submit(submission()).toCompletableFuture().join(); + + var recorded = harness.received().get(0); + assertThat(recorded.header("Authorization").orElseThrow()).startsWith("AWS4-HMAC-SHA256"); + assertThat(recorded.header("X-Amz-Content-Sha256")).isPresent(); + assertThat(recorded.uri().toString()).doesNotContain(ProviderFixtures.SECRET_EMAIL); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpNotificationProviderAdapterTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpNotificationProviderAdapterTest.java new file mode 100644 index 00000000..c9d25a39 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpNotificationProviderAdapterTest.java @@ -0,0 +1,141 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector; +import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures; +import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation; +import dev.caskeleton.application.notification.platform.api.delivery.DeliveryOutcome; +import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel; +import dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.contact.EmailAddress; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import jakarta.mail.Session; +import java.time.Duration; +import java.util.Optional; +import java.util.Properties; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.Test; + +class SmtpNotificationProviderAdapterTest { + + private final ContactPointProtector protector = + new AesGcmContactPointProtector(SecurityFixtures.keys()); + + @Test + void finalTwoFiftyMeansProviderAcceptedNotDelivered() { + var result = adapter(message -> {}).submit(submission()).toCompletableFuture().join(); + + assertThat(result.submissionOutcome()).isEqualTo(SubmissionOutcome.CONFIRMED_ACCEPTED); + assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED); + assertThat(result.deliveryOutcome()).isEqualTo(DeliveryOutcome.UNKNOWN); + } + + @Test + void connectionLossAfterDataIsAmbiguous() { + var result = + adapter( + message -> { + throw new SmtpDispatchException( + "CONNECTION_RESET_AFTER_DATA", Optional.empty(), true, null); + }) + .submit(submission()) + .toCompletableFuture() + .join(); + + assertThat(result.confirmation()).isEqualTo(AttemptConfirmation.AMBIGUOUS); + assertThat(result.submissionOutcome()).isEqualTo(SubmissionOutcome.AMBIGUOUS); + assertThat(result.evidenceLevel()).isNotEqualTo(EvidenceLevel.PROVIDER_ACCEPTED); + assertThat(result.executionEvidence().requestBodyCommitted().value()).isTrue(); + } + + @Test + void aFailureBeforeDataIsASafeRetry() { + var result = + adapter( + message -> { + throw new SmtpDispatchException("CONNECT_FAILED", Optional.empty(), false, null); + }) + .submit(submission()) + .toCompletableFuture() + .join(); + + assertThat(result.submissionOutcome()).isEqualTo(SubmissionOutcome.NOT_SUBMITTED); + assertThat(result.failure().orElseThrow().retryable()).isTrue(); + } + + @Test + void fourYankeeZuluIsTransientAndFiveIsPermanent() { + var transientResult = + adapter( + message -> { + throw new SmtpDispatchException("BUSY", Optional.of(451), false, null); + }) + .submit(submission()) + .toCompletableFuture() + .join(); + var permanentResult = + adapter( + message -> { + throw new SmtpDispatchException("REFUSED", Optional.of(554), false, null); + }) + .submit(submission()) + .toCompletableFuture() + .join(); + + assertThat(transientResult.failure().orElseThrow().category()) + .isEqualTo(FailureCategory.TRANSIENT_PROVIDER); + assertThat(permanentResult.failure().orElseThrow().category()) + .isEqualTo(FailureCategory.PERMANENT_PROVIDER); + } + + @Test + void aRejectedMailboxInvalidatesTheRecipientRatherThanRetrying() { + var result = + adapter( + message -> { + throw new SmtpDispatchException("NO_SUCH_USER", Optional.of(550), false, null); + }) + .submit(submission()) + .toCompletableFuture() + .join(); + + assertThat(result.failure().orElseThrow().category()) + .isEqualTo(FailureCategory.INVALID_RECIPIENT); + assertThat(result.failure().orElseThrow().retryable()).isFalse(); + } + + private SmtpNotificationProviderAdapter adapter(SmtpDispatch dispatch) { + var properties = + new SmtpProviderProperties( + "smtp.example.com", + 587, + SmtpProviderProperties.TlsMode.STARTTLS_REQUIRED, + "noreply@example.com", + Duration.ofSeconds(1), + Duration.ofSeconds(3), + Duration.ofSeconds(3), + 4); + return new SmtpNotificationProviderAdapter( + dispatch, + new SmtpMimeMessageFactory(Session.getInstance(new Properties())), + new SmtpFailureClassifier(), + protector, + properties, + Executors.newSingleThreadExecutor()); + } + + private ProviderSubmission submission() { + return ProviderFixtures.submission( + ProviderFixtures.profile("smtp-primary", "smtp", Channel.EMAIL), + Channel.EMAIL, + ProviderFixtures.email(), + protector, + EmailAddress.parse(ProviderFixtures.SECRET_EMAIL), + Optional.empty()); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAndProjectionTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAndProjectionTest.java new file mode 100644 index 00000000..9ea49083 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAndProjectionTest.java @@ -0,0 +1,133 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.callback.CallbackRequest; +import dev.caskeleton.application.notification.platform.callback.NormalizedEventType; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import org.junit.jupiter.api.Test; + +class TwilioCallbackAndProjectionTest { + + private static final String CALLBACK_URL = + "https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary"; + + private final TwilioProviderProperties properties = + new TwilioProviderProperties( + java.net.URI.create("https://api.twilio.example"), + "AC123", + Optional.of("MG123"), + Optional.empty(), + CALLBACK_URL, + java.time.Duration.ofSeconds(3), + java.time.Duration.ofHours(12)); + + private final TwilioCallbackAdapter adapter = + new TwilioCallbackAdapter( + new TwilioSignatureValidator(), + new TwilioStatusNormalizer(), + properties, + SecurityFixtures.keys()); + + @Test + void aValidSignatureIsAcceptedAndNormalized() { + Map parameters = + new TreeMap<>(Map.of("MessageSid", "SM1", "MessageStatus", "delivered")); + var request = callback(parameters, signature(parameters)); + + var verification = adapter.verify(request); + assertThat(verification.valid()).isTrue(); + + var events = adapter.normalize(verification.verifiedCallback().orElseThrow()); + assertThat(events).hasSize(1); + assertThat(events.get(0).type()).isEqualTo(NormalizedEventType.DELIVERY_CONFIRMED); + assertThat(events.get(0).providerRequestId()).contains("SM1"); + } + + @Test + void invalidSignatureIsRejected() { + Map parameters = + new TreeMap<>(Map.of("MessageSid", "SM1", "MessageStatus", "delivered")); + + var verification = adapter.verify(callback(parameters, "not-the-signature")); + + assertThat(verification.valid()).isFalse(); + assertThat(verification.reasonCode()).isEqualTo("TWILIO_SIGNATURE_MISMATCH"); + assertThat(verification.verifiedCallback()).isEmpty(); + } + + @Test + void aTamperedParameterInvalidatesTheSignature() { + Map signed = + new TreeMap<>(Map.of("MessageSid", "SM1", "MessageStatus", "delivered")); + Map tampered = + new TreeMap<>(Map.of("MessageSid", "SM1", "MessageStatus", "failed")); + + assertThat(adapter.verify(callback(tampered, signature(signed))).valid()).isFalse(); + } + + @Test + void statusNamesMapToTheStableVocabulary() { + var normalizer = new TwilioStatusNormalizer(); + Optional at = Optional.of(Instant.parse("2026-08-14T00:00:00Z")); + + assertThat(normalizer.normalize(Map.of("MessageStatus", "queued"), at).type()) + .isEqualTo(NormalizedEventType.PROVIDER_ACCEPTED); + assertThat(normalizer.normalize(Map.of("MessageStatus", "sent"), at).type()) + .isEqualTo(NormalizedEventType.SENT); + assertThat(normalizer.normalize(Map.of("MessageStatus", "undelivered"), at).type()) + .isEqualTo(NormalizedEventType.UNDELIVERED); + assertThat(normalizer.normalize(Map.of("MessageStatus", "brand-new-status"), at).type()) + .isEqualTo(NormalizedEventType.UNKNOWN); + } + + private String signature(Map parameters) { + var validator = new TwilioSignatureValidator(); + byte[] token = SecurityFixtures.keys().activeKey(SecretPurpose.CALLBACK_SIGNING).material(); + // Recompute the value the validator expects, which is also what Twilio would send. + StringBuilder payload = new StringBuilder(CALLBACK_URL); + new TreeMap<>(parameters).forEach((key, value) -> payload.append(key).append(value)); + try { + var mac = javax.crypto.Mac.getInstance("HmacSHA1"); + mac.init(new javax.crypto.spec.SecretKeySpec(token, "HmacSHA1")); + String computed = + java.util.Base64.getEncoder() + .encodeToString(mac.doFinal(payload.toString().getBytes(StandardCharsets.UTF_8))); + assertThat(validator.isValid(CALLBACK_URL, parameters, computed, token)).isTrue(); + return computed; + } catch (java.security.GeneralSecurityException failure) { + throw new IllegalStateException(failure); + } + } + + private static CallbackRequest callback(Map parameters, String signature) { + String form = + parameters.entrySet().stream() + .map( + entry -> + URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8) + + "=" + + URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)) + .reduce((left, right) -> left + "&" + right) + .orElse(""); + return new CallbackRequest( + new ProviderId("twilio"), + new ProviderProfileId("twilio-primary"), + CALLBACK_URL, + "POST", + Optional.of("application/x-www-form-urlencoded"), + Map.of("x-twilio-signature", List.of(signature)), + form.getBytes(StandardCharsets.UTF_8), + Instant.parse("2026-08-14T00:00:00Z")); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackContractTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackContractTest.java new file mode 100644 index 00000000..9b70a603 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackContractTest.java @@ -0,0 +1,103 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.CallbackContract; +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.callback.CallbackRequest; +import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapter; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; + +/** Twilio against the shared callback contract. */ +class TwilioCallbackContractTest extends CallbackContract { + + private static final String CALLBACK_URL = + "https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary"; + + private final TwilioProviderProperties properties = + new TwilioProviderProperties( + java.net.URI.create("https://api.twilio.example"), + "AC123", + Optional.of("MG123"), + Optional.empty(), + CALLBACK_URL, + Duration.ofSeconds(3), + Duration.ofHours(12)); + + private final TwilioCallbackAdapter adapter = + new TwilioCallbackAdapter( + new TwilioSignatureValidator(), + new TwilioStatusNormalizer(), + properties, + SecurityFixtures.keys()); + + @Override + protected ProviderCallbackAdapter adapter() { + return adapter; + } + + @Override + protected CallbackRequest signedRequest() { + Map parameters = delivered(); + return callback(parameters, signature(parameters)); + } + + @Override + protected CallbackRequest tamperedRequest() { + Map failed = + new TreeMap<>(Map.of("MessageSid", "SM1", "MessageStatus", "failed")); + return callback(failed, signature(delivered())); + } + + @Override + protected CallbackRequest unsignedRequest() { + return callback(delivered(), ""); + } + + private static Map delivered() { + return new TreeMap<>(Map.of("MessageSid", "SM1", "MessageStatus", "delivered")); + } + + private static String signature(Map parameters) { + byte[] token = SecurityFixtures.keys().activeKey(SecretPurpose.CALLBACK_SIGNING).material(); + StringBuilder payload = new StringBuilder(CALLBACK_URL); + new TreeMap<>(parameters).forEach((key, value) -> payload.append(key).append(value)); + try { + var mac = javax.crypto.Mac.getInstance("HmacSHA1"); + mac.init(new javax.crypto.spec.SecretKeySpec(token, "HmacSHA1")); + return java.util.Base64.getEncoder() + .encodeToString(mac.doFinal(payload.toString().getBytes(StandardCharsets.UTF_8))); + } catch (java.security.GeneralSecurityException failure) { + throw new IllegalStateException(failure); + } + } + + private static CallbackRequest callback(Map parameters, String signature) { + String form = + parameters.entrySet().stream() + .map( + entry -> + URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8) + + "=" + + URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)) + .reduce((left, right) -> left + "&" + right) + .orElse(""); + return new CallbackRequest( + new ProviderId("twilio"), + new ProviderProfileId("twilio-primary"), + CALLBACK_URL, + "POST", + Optional.of("application/x-www-form-urlencoded"), + Map.of("x-twilio-signature", List.of(signature)), + form.getBytes(StandardCharsets.UTF_8), + Instant.parse("2026-08-14T00:00:00Z")); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapterTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapterTest.java new file mode 100644 index 00000000..6cda768d --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapterTest.java @@ -0,0 +1,121 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector; +import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderAdapterContract; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFaultHarness; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures; +import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.contact.PhoneNumber; +import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class TwilioSmsProviderAdapterTest extends ProviderAdapterContract { + + private final ProviderFaultHarness harness = new ProviderFaultHarness(); + private final ContactPointProtector protector = + new AesGcmContactPointProtector(SecurityFixtures.keys()); + + @AfterEach + void stopHarness() { + harness.close(); + } + + private TwilioProviderProperties properties() { + return new TwilioProviderProperties( + harness.baseUri(), + "AC123", + Optional.of("MG123"), + Optional.empty(), + "https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary", + Duration.ofSeconds(3), + Duration.ofHours(12)); + } + + @Override + protected NotificationProviderAdapter adapter() { + return new TwilioSmsProviderAdapter( + new JdkNotificationHttpGateway(Duration.ofSeconds(2)), + new TwilioRequestMapper(properties()), + new TwilioFailureClassifier(), + protector, + SecurityFixtures.keys()); + } + + @Override + protected ProviderFaultHarness harness() { + return harness; + } + + @Override + protected ProviderSubmission submission() { + return ProviderFixtures.submission( + ProviderFixtures.profile("twilio-primary", "twilio", Channel.SMS), + Channel.SMS, + ProviderFixtures.sms(), + protector, + new PhoneNumber(ProviderFixtures.SECRET_PHONE), + Optional.empty()); + } + + @Override + protected String successBody() { + return "{\"sid\":\"SM1\",\"status\":\"accepted\"}"; + } + + @Test + void acceptedStatusIsProviderAcceptedOnly() { + harness.respondWith(201, successBody(), Map.of()); + + var result = adapter().submit(submission()).toCompletableFuture().join(); + + assertThat(result.providerRequestId()).contains("SM1"); + assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED); + assertThat(result.deliveryOutcome()) + .isEqualTo( + dev.caskeleton.application.notification.platform.api.delivery.DeliveryOutcome.UNKNOWN); + assertThat(result.nativeStatus()).contains("accepted"); + } + + @Test + void provider429IsThrottled() { + harness.respondWith(429, "{\"code\":20429}", Map.of("retry-after", "10")); + + var result = adapter().submit(submission()).toCompletableFuture().join(); + + assertThat(result.failure().orElseThrow().category()).isEqualTo(FailureCategory.THROTTLED); + assertThat(result.failure().orElseThrow().retryAfter()).contains(Duration.ofSeconds(10)); + } + + @Test + void anInvalidDestinationInvalidatesTheContactPointInsteadOfRetrying() { + harness.respondWith(400, "{\"code\":21211}", Map.of()); + + var result = adapter().submit(submission()).toCompletableFuture().join(); + + assertThat(result.failure().orElseThrow().category()) + .isEqualTo(FailureCategory.INVALID_RECIPIENT); + assertThat(result.failure().orElseThrow().retryable()).isFalse(); + } + + @Test + void theStatusCallbackUrlComesFromTheProfileNotTheRequest() { + harness.respondWith(201, successBody(), Map.of()); + + adapter().submit(submission()).toCompletableFuture().join(); + + assertThat(harness.received().get(0).bodyAsString()) + .contains("StatusCallback=https%3A%2F%2Fcallback.example.com"); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapterTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapterTest.java new file mode 100644 index 00000000..5e28ddcb --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapterTest.java @@ -0,0 +1,112 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webhook; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector; +import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFaultHarness; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures; +import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.contact.InAppRecipientRef; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class WebhookNotificationProviderAdapterTest { + + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-08-14T00:00:00Z"), ZoneOffset.UTC); + + private final ProviderFaultHarness harness = new ProviderFaultHarness(); + private final ContactPointProtector protector = + new AesGcmContactPointProtector(SecurityFixtures.keys()); + + @AfterEach + void stopHarness() { + harness.close(); + } + + @Test + void dynamicTargetNeverInheritsTrustedCredentials() { + harness.respondWith(200, "{}", Map.of()); + + adapter(dynamicSubscription()).submit(submission()).toCompletableFuture().join(); + + var recorded = harness.received().get(0); + assertThat(recorded.header("Authorization")).isEmpty(); + assertThat(recorded.header("Cookie")).isEmpty(); + assertThat(recorded.header(WebhookSignatureStrategy.SIGNATURE_HEADER)).isEmpty(); + } + + @Test + void trustedTargetIsSignedWithATimestamp() { + harness.respondWith(200, "{}", Map.of()); + + adapter(trustedSubscription()).submit(submission()).toCompletableFuture().join(); + + var recorded = harness.received().get(0); + assertThat(recorded.header(WebhookSignatureStrategy.SIGNATURE_HEADER).orElseThrow()) + .startsWith("v1="); + assertThat(recorded.header(WebhookSignatureStrategy.TIMESTAMP_HEADER)).isPresent(); + } + + @Test + void sentWithNoResponseMapsToAmbiguous() { + harness.acceptBodyThenDropConnection(); + + var result = adapter(trustedSubscription()).submit(submission()).toCompletableFuture().join(); + + assertThat(result.confirmation()).isEqualTo(AttemptConfirmation.AMBIGUOUS); + assertThat(result.executionEvidence().requestBodyCommitted().value()).isTrue(); + } + + @Test + void aReceiverErrorBodyIsBoundedInTheDiagnostic() { + harness.respondWith(500, "x".repeat(5000), Map.of()); + + var result = adapter(trustedSubscription()).submit(submission()).toCompletableFuture().join(); + + assertThat(result.failure().orElseThrow().nativeCode().orElseThrow().length()).isLessThan(1000); + } + + private WebhookNotificationProviderAdapter adapter(WebhookSubscription subscription) { + var gateway = new JdkNotificationHttpGateway(Duration.ofSeconds(2)); + return new WebhookNotificationProviderAdapter( + gateway, + gateway, + new WebhookSignatureStrategy(), + SecurityFixtures.keys(), + submission -> subscription, + Duration.ofSeconds(3), + CLOCK); + } + + private WebhookSubscription trustedSubscription() { + return new WebhookSubscription( + "sub-1", harness.baseUri().resolve("/hook"), true, Optional.of("callback-sign")); + } + + private WebhookSubscription dynamicSubscription() { + return new WebhookSubscription( + "sub-2", harness.baseUri().resolve("/hook"), false, Optional.empty()); + } + + private ProviderSubmission submission() { + return ProviderFixtures.submission( + ProviderFixtures.profile("webhook-main", "webhook", Channel.WEBHOOK), + Channel.WEBHOOK, + ProviderFixtures.webPush(), + protector, + new InAppRecipientRef("user-1"), + Optional.empty()); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushCryptoTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushCryptoTest.java new file mode 100644 index 00000000..fa630ee7 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushCryptoTest.java @@ -0,0 +1,270 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue; +import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.SecureRandom; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import javax.crypto.Cipher; +import javax.crypto.KeyAgreement; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import org.junit.jupiter.api.Test; + +/** + * RFC 8291 encryption and RFC 8292 VAPID, verified by actually decrypting. + * + *

The subscriber side is reconstructed here from a real P-256 key pair rather than asserted + * against a recorded fixture. A recorded ciphertext would still match after a change that broke + * interoperability, because both sides of the comparison would be this code; a round trip through + * the user-agent half of the protocol will not. + */ +class WebPushCryptoTest { + + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-08-14T00:00:00Z"), ZoneOffset.UTC); + private static final URI ENDPOINT = URI.create("https://push.example.com/send/abc123"); + + @Test + void payloadIsEncryptedForTheSubscriptionAndDecryptsBackToThePlaintext() throws Exception { + KeyPair userAgent = p256(); + byte[] authSecret = authSecret(); + var subscription = subscription(userAgent, authSecret); + byte[] plaintext = "hello".getBytes(StandardCharsets.UTF_8); + + var encrypted = new Rfc8291Aes128GcmEncryptor().encrypt(subscription, plaintext); + + assertThat(encrypted.contentEncoding()).isEqualTo("aes128gcm"); + assertThat(indexOf(encrypted.body(), plaintext)).isEqualTo(-1); + assertThat(decrypt(encrypted.body(), userAgent, authSecret)).isEqualTo(plaintext); + } + + @Test + void everyMessageUsesAFreshEphemeralKeySoTwoSendsNeverShareAKeyStream() { + KeyPair userAgent = p256(); + byte[] authSecret = authSecret(); + var subscription = subscription(userAgent, authSecret); + var encryptor = new Rfc8291Aes128GcmEncryptor(); + byte[] plaintext = "same message".getBytes(StandardCharsets.UTF_8); + + byte[] first = encryptor.encrypt(subscription, plaintext).body(); + byte[] second = encryptor.encrypt(subscription, plaintext).body(); + + assertThat(first).isNotEqualTo(second); + // Salt is the first 16 bytes; a repeated salt would mean a repeated content encryption key. + assertThat(Arrays.copyOf(first, 16)).isNotEqualTo(Arrays.copyOf(second, 16)); + } + + @Test + void aPayloadLargerThanTheRecordIsRefusedBeforeAnyProviderCall() { + var subscription = subscription(p256(), authSecret()); + + assertThatThrownBy(() -> new Rfc8291Aes128GcmEncryptor().encrypt(subscription, new byte[4096])) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void vapidAudienceIsTheEndpointOriginAndNotAConfiguredConstant() { + String jwt = + new VapidJwtSigner(CLOCK, "mailto:ops@example.com") + .sign(ENDPOINT, vapidKey(), Duration.ofHours(1)); + + String claims = new String(decodeSegment(jwt, 1), StandardCharsets.UTF_8); + assertThat(claims).contains("\"aud\":\"https://push.example.com\""); + assertThat(claims).contains("\"sub\":\"mailto:ops@example.com\""); + assertThat(claims) + .contains("\"exp\":" + CLOCK.instant().plus(Duration.ofHours(1)).getEpochSecond()); + // ES256 in JOSE is fixed-width r || s, never the JVM's variable-length DER encoding. + assertThat(decodeSegment(jwt, 2)).hasSize(64); + } + + @Test + void aTokenLifetimeBeyondTwelveHoursIsRefused() { + var signer = new VapidJwtSigner(CLOCK, "mailto:ops@example.com"); + + assertThatThrownBy(() -> signer.sign(ENDPOINT, vapidKey(), Duration.ofHours(13))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void aSubjectThatIsNotAMailtoOrHttpsUriIsRefused() { + assertThatThrownBy(() -> new VapidJwtSigner(CLOCK, "ops@example.com")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void authorizationHeaderCarriesBothTheTokenAndTheApplicationServerKey() { + String header = + new VapidJwtSigner(CLOCK, "mailto:ops@example.com") + .authorization(ENDPOINT, vapidKey(), "BPublicKey"); + + assertThat(header).startsWith("vapid t="); + assertThat(header).contains(", k=BPublicKey"); + } + + @Test + void aSubscriptionKeepsSigningWithItsOwnKeyIdAndIsReportedAsNeedingMigration() { + var registry = + new VapidKeyRegistry(vapidKeys(), "vapid-2", Map.of("vapid-1", "BOld", "vapid-2", "BNew")); + var subscription = subscription(p256(), authSecret(), "vapid-1"); + + assertThat(registry.signingKeyFor(subscription).keyId()).isEqualTo("vapid-1"); + assertThat(registry.publicKeyFor(subscription)).isEqualTo("BOld"); + assertThat(registry.requiresSubscriptionMigration(subscription)).isTrue(); + assertThat(registry.activeKeyId()).isEqualTo("vapid-2"); + } + + @Test + void aSubscriptionOnTheActiveKeyNeedsNoMigration() { + var registry = new VapidKeyRegistry(vapidKeys(), "vapid-1", Map.of("vapid-1", "BOld")); + + assertThat( + registry.requiresSubscriptionMigration(subscription(p256(), authSecret(), "vapid-1"))) + .isFalse(); + } + + @Test + void receiptsAreOnlyRequestedWhenTheProfileDeclaresThemAndTheServiceAdvertisesOne() { + var supported = + WebPushReceiptCapability.forProfile( + new WebPushProviderProperties( + "BKey", Duration.ofHours(1), 4096L, true, Duration.ofSeconds(5))); + var unsupported = WebPushReceiptCapability.unsupported(); + List links = + List.of("; rel=\"" + WebPushReceiptCapability.RECEIPT_LINK_RELATION + "\""); + + assertThat(supported.preferHeader()).contains("respond-async"); + assertThat(supported.receiptSubscription(links)).contains(URI.create("/receipts/9")); + assertThat(supported.receiptSubscription(List.of("; rel=\"urn:ietf:params:push\""))) + .isEmpty(); + assertThat(unsupported.preferHeader()).isEmpty(); + assertThat(unsupported.receiptSubscription(links)).isEmpty(); + } + + private static WebPushSubscriptionValue subscription(KeyPair userAgent, byte[] authSecret) { + return subscription(userAgent, authSecret, "vapid-1"); + } + + private static WebPushSubscriptionValue subscription( + KeyPair userAgent, byte[] authSecret, String vapidKeyId) { + return new WebPushSubscriptionValue( + ENDPOINT, + Rfc8291Aes128GcmEncryptor.encodePoint((ECPublicKey) userAgent.getPublic()), + authSecret, + vapidKeyId); + } + + /** + * The user-agent half of RFC 8291 §3.4, so the assertion is interoperability, not self-agreement. + */ + private static byte[] decrypt(byte[] body, KeyPair userAgent, byte[] authSecret) + throws Exception { + byte[] salt = Arrays.copyOf(body, 16); + int keyIdLength = body[20] & 0xFF; + byte[] applicationServerPublic = Arrays.copyOfRange(body, 21, 21 + keyIdLength); + byte[] ciphertext = Arrays.copyOfRange(body, 21 + keyIdLength, body.length); + + KeyAgreement agreement = KeyAgreement.getInstance("ECDH"); + agreement.init(userAgent.getPrivate()); + agreement.doPhase(Rfc8291Aes128GcmEncryptor.decodePoint(applicationServerPublic), true); + byte[] sharedSecret = agreement.generateSecret(); + + byte[] userAgentPublic = + Rfc8291Aes128GcmEncryptor.encodePoint((ECPublicKey) userAgent.getPublic()); + byte[] info = new byte[14 + 65 + 65]; + byte[] label = "WebPush: info\0".getBytes(StandardCharsets.US_ASCII); + System.arraycopy(label, 0, info, 0, label.length); + System.arraycopy(userAgentPublic, 0, info, label.length, 65); + System.arraycopy(applicationServerPublic, 0, info, label.length + 65, 65); + + byte[] ikm = Rfc8291Aes128GcmEncryptor.hkdf(authSecret, sharedSecret, info, 32); + byte[] key = + Rfc8291Aes128GcmEncryptor.hkdf( + salt, ikm, "Content-Encoding: aes128gcm\0".getBytes(StandardCharsets.US_ASCII), 16); + byte[] nonce = + Rfc8291Aes128GcmEncryptor.hkdf( + salt, ikm, "Content-Encoding: nonce\0".getBytes(StandardCharsets.US_ASCII), 12); + + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init( + Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, nonce)); + byte[] padded = cipher.doFinal(ciphertext); + return Arrays.copyOf(padded, padded.length - 1); + } + + private static KeyPair p256() { + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + return generator.generateKeyPair(); + } catch (java.security.GeneralSecurityException failure) { + throw new IllegalStateException(failure); + } + } + + private static byte[] authSecret() { + byte[] secret = new byte[16]; + new SecureRandom().nextBytes(secret); + return secret; + } + + private static SecretKeyMaterial vapidKey() { + return new SecretKeyMaterial( + "vapid-1", SecretPurpose.VAPID_SIGNING, p256().getPrivate().getEncoded()); + } + + private static SecretMaterialProvider vapidKeys() { + SecretKeyMaterial first = + new SecretKeyMaterial( + "vapid-1", SecretPurpose.VAPID_SIGNING, p256().getPrivate().getEncoded()); + SecretKeyMaterial second = + new SecretKeyMaterial( + "vapid-2", SecretPurpose.VAPID_SIGNING, p256().getPrivate().getEncoded()); + return new SecretMaterialProvider() { + + @Override + public SecretKeyMaterial activeKey(SecretPurpose purpose) { + return second; + } + + @Override + public SecretKeyMaterial keyById(String keyId) { + return "vapid-1".equals(keyId) ? first : second; + } + }; + } + + private static byte[] decodeSegment(String jwt, int index) { + return Base64.getUrlDecoder().decode(jwt.split("\\.", -1)[index]); + } + + private static int indexOf(byte[] haystack, byte[] needle) { + outer: + for (int start = 0; start + needle.length <= haystack.length; start++) { + for (int offset = 0; offset < needle.length; offset++) { + if (haystack[start + offset] != needle[offset]) { + continue outer; + } + } + return start; + } + return -1; + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushProviderAdapterTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushProviderAdapterTest.java new file mode 100644 index 00000000..80a09346 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushProviderAdapterTest.java @@ -0,0 +1,176 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector; +import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFaultHarness; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures; +import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.ProviderConfigurationException; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import java.net.URI; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.SecureRandom; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class WebPushProviderAdapterTest { + + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-08-14T00:00:00Z"), ZoneOffset.UTC); + + private final ProviderFaultHarness harness = new ProviderFaultHarness(); + private final ContactPointProtector protector = + new AesGcmContactPointProtector(SecurityFixtures.keys()); + + @AfterEach + void stopHarness() { + harness.close(); + } + + @Test + void ttlHeaderIsRequiredAndAcceptanceIsNotDelivery() { + harness.respondWith(201, "", Map.of("location", "https://push.example/receipt/1")); + + var result = + adapter() + .submit(submission(Optional.of(CLOCK.instant().plusSeconds(60)))) + .toCompletableFuture() + .join(); + + assertThat(harness.received().get(0).header("ttl")).contains("60"); + assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED); + assertThat(result.deliveryOutcome()) + .isEqualTo( + dev.caskeleton.application.notification.platform.api.delivery.DeliveryOutcome.UNKNOWN); + } + + @Test + void expiredSubscriptionIsInvalidated() { + harness.respondWith(404, "", Map.of()); + + var result = + adapter() + .submit(submission(Optional.of(CLOCK.instant().plusSeconds(60)))) + .toCompletableFuture() + .join(); + + assertThat(result.failure().orElseThrow().category()) + .isEqualTo(FailureCategory.INVALID_RECIPIENT); + } + + @Test + void providerSpecific410IsAlsoAnInvalidation() { + harness.respondWith(410, "", Map.of()); + + var result = + adapter() + .submit(submission(Optional.of(CLOCK.instant().plusSeconds(60)))) + .toCompletableFuture() + .join(); + + assertThat(result.failure().orElseThrow().category()) + .isEqualTo(FailureCategory.INVALID_RECIPIENT); + } + + @Test + void missingExpiryCannotCreateAWebPushAttempt() { + harness.respondWith(201, "", Map.of()); + + assertThatThrownBy( + () -> adapter().submit(submission(Optional.empty())).toCompletableFuture().join()) + .isInstanceOf(ProviderConfigurationException.class); + } + + @Test + void thePayloadIsEncryptedAndCarriesTheRfcContentEncoding() { + harness.respondWith(201, "", Map.of()); + + adapter() + .submit(submission(Optional.of(CLOCK.instant().plusSeconds(60)))) + .toCompletableFuture() + .join(); + + var recorded = harness.received().get(0); + assertThat(recorded.header("content-encoding")).contains("aes128gcm"); + assertThat(recorded.header("authorization").orElseThrow()).startsWith("vapid t="); + assertThat(recorded.bodyAsString()).doesNotContain("Contract title"); + } + + @Test + void payloadEncryptionRoundTripsThroughTheSubscriptionKeys() { + var encryptor = new Rfc8291Aes128GcmEncryptor(new SecureRandom()); + var subscription = subscription(); + + var encrypted = + encryptor.encrypt(subscription, "hello".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + + assertThat(encrypted.contentEncoding()).isEqualTo("aes128gcm"); + assertThat(new String(encrypted.body(), java.nio.charset.StandardCharsets.ISO_8859_1)) + .doesNotContain("hello"); + // salt(16) + rs(4) + idlen(1) + key(65) is the RFC 8188 record header. + assertThat(encrypted.body().length).isGreaterThan(16 + 4 + 1 + 65); + } + + private WebPushNotificationProviderAdapter adapter() { + var properties = + new WebPushProviderProperties( + "BFakePublicKeyForTests", Duration.ofHours(1), 4096L, false, Duration.ofSeconds(3)); + return new WebPushNotificationProviderAdapter( + new JdkNotificationHttpGateway(Duration.ofSeconds(2)), + new WebPushRequestMapper( + new Rfc8291Aes128GcmEncryptor(new SecureRandom()), + // Signing needs a PKCS#8 EC key, which VapidJwtSignerTest covers; this keeps the + // transport test about TTL, encryption and status mapping. + (endpoint, signingKey, publicKey) -> "vapid t=stub-token, k=" + publicKey, + SecurityFixtures.keys(), + properties, + CLOCK), + new WebPushFailureClassifier(), + protector, + properties); + } + + private ProviderSubmission submission(Optional expiresAt) { + return ProviderFixtures.submission( + ProviderFixtures.profile("webpush-main", "webpush", Channel.WEB_PUSH), + Channel.WEB_PUSH, + ProviderFixtures.webPush(), + protector, + subscription(), + expiresAt); + } + + private WebPushSubscriptionValue subscription() { + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + KeyPair pair = generator.generateKeyPair(); + byte[] authSecret = new byte[16]; + new SecureRandom().nextBytes(authSecret); + return new WebPushSubscriptionValue( + URI.create(harness.baseUri() + "/push/subscription-1"), + Rfc8291Aes128GcmEncryptor.encodePoint((ECPublicKey) pair.getPublic()), + authSecret, + "vapid-key-1"); + } catch (java.security.GeneralSecurityException failure) { + throw new IllegalStateException(failure); + } + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmContactPointProtectorTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmContactPointProtectorTest.java new file mode 100644 index 00000000..ca195f7f --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmContactPointProtectorTest.java @@ -0,0 +1,89 @@ +package dev.caskeleton.adapter.outbound.notification.platform.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.notification.platform.contact.ApnsDeviceToken; +import dev.caskeleton.application.notification.platform.contact.ApnsEnvironment; +import dev.caskeleton.application.notification.platform.contact.EmailAddress; +import dev.caskeleton.application.notification.platform.contact.PhoneNumber; +import dev.caskeleton.application.notification.platform.security.AccessContext; +import org.junit.jupiter.api.Test; + +class AesGcmContactPointProtectorTest { + + private final AesGcmContactPointProtector protector = + new AesGcmContactPointProtector(SecurityFixtures.keys()); + + @Test + void encryptsRoundTripAndProducesStableLookupFingerprint() { + var value = EmailAddress.parse("user@example.com"); + + var first = protector.protect(value); + var second = protector.protect(value); + + assertThat(first.ciphertext()).isNotEqualTo(second.ciphertext()); + assertThat(first.lookupHmac()).isEqualTo(second.lookupHmac()); + assertThat(protector.reveal(first, AccessContext.dispatch("ses-primary"))).isEqualTo(value); + } + + @Test + void encryptionAndHmacKeysMustDiffer() { + var sharedKeyProtector = + new AesGcmContactPointProtector(SecurityFixtures.keysWithSameMaterial()); + assertThatThrownBy(() -> sharedKeyProtector.protect(EmailAddress.parse("user@example.com"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void encryptionKeyMustBe256Bits() { + var weakProtector = new AesGcmContactPointProtector(SecurityFixtures.shortEncryptionKey()); + assertThatThrownBy(() -> weakProtector.protect(EmailAddress.parse("user@example.com"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("256"); + } + + @Test + void differentContactKindsWithTheSameTextGetDifferentFingerprints() { + var phone = protector.fingerprint(new PhoneNumber("+821012345678")); + var apns = + protector.fingerprint(new ApnsDeviceToken("+821012345678", ApnsEnvironment.PRODUCTION)); + assertThat(phone).isNotEqualTo(apns); + } + + @Test + void aCiphertextCannotBeReplayedUnderAnotherContactKind() { + var protectedEmail = protector.protect(EmailAddress.parse("user@example.com")); + var moved = + new dev.caskeleton.application.notification.platform.security.ProtectedContactPoint( + dev.caskeleton.application.notification.platform.contact.ContactPointType.PHONE, + protectedEmail.keyId(), + protectedEmail.nonce(), + protectedEmail.ciphertext(), + protectedEmail.lookupHmac()); + + assertThatThrownBy(() -> protector.reveal(moved, AccessContext.dispatch("ses-primary"))) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void protectedValuesNeverPrintTheirContents() { + var protectedEmail = protector.protect(EmailAddress.parse("user@example.com")); + assertThat(protectedEmail.toString()).doesNotContain("user@example.com").contains("redacted"); + } + + @Test + void anUnknownKeyIdIsRefusedInsteadOfSilentlyFallingBack() { + var protectedEmail = protector.protect(EmailAddress.parse("user@example.com")); + var rotated = + new dev.caskeleton.application.notification.platform.security.ProtectedContactPoint( + protectedEmail.type(), + "retired-key", + protectedEmail.nonce(), + protectedEmail.ciphertext(), + protectedEmail.lookupHmac()); + + assertThatThrownBy(() -> protector.reveal(rotated, AccessContext.dispatch("ses-primary"))) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/SecurityFixtures.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/SecurityFixtures.java new file mode 100644 index 00000000..4a89ca09 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/SecurityFixtures.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.notification.platform.security; + +import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.util.Map; + +/** Deterministic key material shared by the protection and provider contract tests. */ +public final class SecurityFixtures { + + private SecurityFixtures() {} + + public static SecretMaterialProvider keys() { + return new SettingsSecretMaterialProvider( + Map.of( + SecretPurpose.CONTACT_ENCRYPTION, + new SecretKeyMaterial( + "enc-1", SecretPurpose.CONTACT_ENCRYPTION, filled((byte) 0x11, 32)), + SecretPurpose.CONTACT_LOOKUP_HMAC, + new SecretKeyMaterial( + "mac-1", SecretPurpose.CONTACT_LOOKUP_HMAC, filled((byte) 0x22, 32)), + SecretPurpose.CALLBACK_SIGNING, + new SecretKeyMaterial("cb-1", SecretPurpose.CALLBACK_SIGNING, filled((byte) 0x33, 32)), + SecretPurpose.PROVIDER_CREDENTIAL, + new SecretKeyMaterial( + "cred-1", SecretPurpose.PROVIDER_CREDENTIAL, filled((byte) 0x44, 32)), + SecretPurpose.PAYLOAD_ENCRYPTION, + new SecretKeyMaterial( + "payload-1", SecretPurpose.PAYLOAD_ENCRYPTION, filled((byte) 0x55, 32)), + SecretPurpose.VAPID_SIGNING, + new SecretKeyMaterial("vapid-1", SecretPurpose.VAPID_SIGNING, filled((byte) 0x66, 32))), + Map.of()); + } + + public static SecretMaterialProvider keysWithSameMaterial() { + return new SettingsSecretMaterialProvider( + Map.of( + SecretPurpose.CONTACT_ENCRYPTION, + new SecretKeyMaterial( + "enc-1", SecretPurpose.CONTACT_ENCRYPTION, filled((byte) 0x11, 32)), + SecretPurpose.CONTACT_LOOKUP_HMAC, + new SecretKeyMaterial( + "mac-1", SecretPurpose.CONTACT_LOOKUP_HMAC, filled((byte) 0x11, 32))), + Map.of()); + } + + public static SecretMaterialProvider shortEncryptionKey() { + return new SettingsSecretMaterialProvider( + Map.of( + SecretPurpose.CONTACT_ENCRYPTION, + new SecretKeyMaterial( + "enc-1", SecretPurpose.CONTACT_ENCRYPTION, filled((byte) 0x11, 16)), + SecretPurpose.CONTACT_LOOKUP_HMAC, + new SecretKeyMaterial( + "mac-1", SecretPurpose.CONTACT_LOOKUP_HMAC, filled((byte) 0x22, 32))), + Map.of()); + } + + private static byte[] filled(byte value, int length) { + byte[] material = new byte[length]; + java.util.Arrays.fill(material, value); + return material; + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/template/CanonicalNotificationRendererTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/template/CanonicalNotificationRendererTest.java new file mode 100644 index 00000000..6ba89f7f --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/template/CanonicalNotificationRendererTest.java @@ -0,0 +1,148 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.notification.platform.api.TemplateSelection; +import dev.caskeleton.application.notification.platform.api.content.EmailContent; +import dev.caskeleton.application.notification.platform.api.error.TemplateVariableValidationException; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.template.NotificationTemplateVersion; +import dev.caskeleton.application.notification.platform.template.RenderCommand; +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 java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class CanonicalNotificationRendererTest { + + private static final String REQUIRED_CODE_SCHEMA = + "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"," + + "\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\"," + + "\"minLength\":6}},\"required\":[\"code\"]}"; + + @Test + void rejectsMissingRequiredVariableBeforeProviderCall() { + var renderer = + renderer(new VariableSchema(REQUIRED_CODE_SCHEMA, Set.of("code"), Set.of("code"))); + + assertThatThrownBy(() -> renderer.render(command(Map.of()))) + .isInstanceOf(TemplateVariableValidationException.class); + } + + @Test + void rejectsAVariableThatViolatesTheSchema() { + var renderer = renderer(new VariableSchema(REQUIRED_CODE_SCHEMA, Set.of("code"), Set.of())); + + assertThatThrownBy(() -> renderer.render(command(Map.of("code", "123")))) + .isInstanceOf(TemplateVariableValidationException.class); + } + + @Test + void validationFailuresNeverEchoTheSecretVariable() { + var renderer = + renderer(new VariableSchema(REQUIRED_CODE_SCHEMA, Set.of("code"), Set.of("code"))); + + assertThatThrownBy(() -> renderer.render(command(Map.of("code", "12345")))) + .hasMessageNotContaining("12345"); + } + + @Test + void sameVersionAndVariablesProduceSameDigest() { + var renderer = renderer(VariableSchema.NONE); + var command = command(Map.of("code", "654321")); + + assertThat(renderer.render(command).contentDigest()) + .isEqualTo(renderer.render(command).contentDigest()); + } + + @Test + void differentVariablesProduceDifferentDigests() { + var renderer = renderer(VariableSchema.NONE); + + assertThat(renderer.render(command(Map.of("code", "111111"))).contentDigest()) + .isNotEqualTo(renderer.render(command(Map.of("code", "222222"))).contentDigest()); + } + + @Test + void rendersTheChannelContentType() { + var rendered = renderer(VariableSchema.NONE).render(command(Map.of("code", "654321"))); + + assertThat(rendered.content()).isInstanceOf(EmailContent.class); + assertThat(((EmailContent) rendered.content()).textBody()).contains("654321"); + assertThat(rendered.resolvedLocale()).isEqualTo(Locale.KOREAN); + } + + @Test + void anUnresolvedPlaceholderFailsInsteadOfRenderingABlank() { + var renderer = renderer(VariableSchema.NONE); + + assertThatThrownBy(() -> renderer.render(command(Map.of()))) + .isInstanceOf( + dev.caskeleton.application.notification.platform.api.error.TemplateRenderingException + .class); + } + + private static CanonicalNotificationRenderer renderer(VariableSchema schema) { + return new CanonicalNotificationRenderer( + Channel.EMAIL, + new FixedTemplateRegistry(schema), + new JsonSchemaVariableValidator(), + new PlaceholderTemplateEngine()); + } + + private static RenderCommand command(Map variables) { + return new RenderCommand( + new TemplateSelection("password-reset", 1, Locale.KOREAN), + Channel.EMAIL, + Locale.KOREAN, + variables, + Optional.empty()); + } + + /** Registry returning one pinned version. */ + private record FixedTemplateRegistry(VariableSchema schema) implements TemplateRegistry { + + @Override + public NotificationTemplateVersion get(TemplateSelection selection) { + return version(); + } + + @Override + public NotificationTemplateVersion resolve( + String templateId, long version, Channel channel, Locale requestedLocale) { + return version(); + } + + @Override + public void publish(NotificationTemplateVersion version) { + throw new UnsupportedOperationException(); + } + + @Override + public void disable(String templateId, long version) { + throw new UnsupportedOperationException(); + } + + private NotificationTemplateVersion version() { + return new NotificationTemplateVersion( + "password-reset", + 1, + Channel.EMAIL, + Locale.KOREAN, + Optional.empty(), + schema, + new TemplateContentDefinition( + Map.of( + TemplateSlot.SUBJECT, "비밀번호 재설정", TemplateSlot.TEXT_BODY, "인증번호는 {code} 입니다.")), + TemplateStatus.PUBLISHED, + "a".repeat(64)); + } + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafNotificationRendererTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafNotificationRendererTest.java new file mode 100644 index 00000000..de4160b1 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafNotificationRendererTest.java @@ -0,0 +1,179 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.notification.platform.api.TemplateSelection; +import dev.caskeleton.application.notification.platform.api.content.EmailContent; +import dev.caskeleton.application.notification.platform.api.error.TemplateRenderingException; +import dev.caskeleton.application.notification.platform.api.error.TemplateVariableValidationException; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.template.NotificationTemplateVersion; +import dev.caskeleton.application.notification.platform.template.RenderCommand; +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 java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.thymeleaf.templatemode.TemplateMode; + +/** + * The Thymeleaf reference renderer. + * + *

The renderer contract — validate before rendering, exact version selection, stable digest — is + * shared with the placeholder engine and asserted again here, because the whole point of the + * composition is that swapping the engine does not change it. + * + *

The escaping test is the reason this engine exists at all. + */ +class ThymeleafNotificationRendererTest { + + private static final String REQUIRED_CODE_SCHEMA = + "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"," + + "\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\"," + + "\"minLength\":6}},\"required\":[\"code\"]}"; + + @Test + void rejectsMissingRequiredVariableBeforeProviderCall() { + var renderer = + renderer(new VariableSchema(REQUIRED_CODE_SCHEMA, Set.of("code"), Set.of("code"))); + + assertThatThrownBy(() -> renderer.render(command(Map.of()))) + .isInstanceOf(TemplateVariableValidationException.class); + } + + @Test + void sameVersionAndVariablesProduceSameDigest() { + var renderer = renderer(VariableSchema.NONE); + var command = command(Map.of("code", "654321")); + + assertThat(renderer.render(command).contentDigest()) + .isEqualTo(renderer.render(command).contentDigest()); + } + + @Test + void differentVariablesProduceDifferentDigests() { + var renderer = renderer(VariableSchema.NONE); + + assertThat(renderer.render(command(Map.of("code", "111111"))).contentDigest()) + .isNotEqualTo(renderer.render(command(Map.of("code", "222222"))).contentDigest()); + } + + @Test + void rendersTheChannelContentAtTheExactSelectedVersion() { + var rendered = renderer(VariableSchema.NONE).render(command(Map.of("code", "654321"))); + + assertThat(rendered.content()).isInstanceOf(EmailContent.class); + assertThat(((EmailContent) rendered.content()).textBody()).contains("654321"); + assertThat(rendered.templateSelection().version()).isEqualTo(1); + assertThat(rendered.resolvedLocale()).isEqualTo(Locale.KOREAN); + } + + @Test + void markupInAVariableIsEscapedRatherThanInjectedIntoTheBody() { + var rendered = + renderer(VariableSchema.NONE).render(command(Map.of("code", ""))); + + // This is what Thymeleaf buys over plain substitution: a notification variable is application + // input, and an HTML email body is a rendering context an injected tag executes in. + String body = ((EmailContent) rendered.content()).textBody(); + assertThat(body).doesNotContain("