feat(notification): implement the notification delivery platform
Maps the 31-module plan onto the registry's 19 leaves as packages; the two edges the registry forbids (provider->httpclient, inbox->messaging) are replaced by application-owned ports. See docs/notification/module-mapping.md. Acceptance is not delivery: ProviderSubmissionResult refuses to carry a delivery outcome, and AMBIGUOUS is a first-class terminal state that blocks automatic retry and fallback until reconciliation resolves it. Providers: SES (SigV4 + SNS callback), Twilio (X-Twilio-Signature + reconciliation), FCM (FID-primary batch), APNs, Web Push (RFC 8030/8291/8292), SMTP and webhook. Contact points are AES-256-GCM encrypted with a separate HMAC lookup fingerprint; nothing raw reaches a log, metric tag or exception. Dispatch commits the attempt row, calls the provider with no transaction open, then records the outcome; the durable queue uses FOR UPDATE SKIP LOCKED. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3b5aee50e3
commit
701ba67456
@@ -26,6 +26,7 @@ readonly EXPECTED_WORKFLOW_LOCK=(
|
|||||||
'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml'
|
'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml'
|
||||||
'59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml'
|
'59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml'
|
||||||
'5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml'
|
'5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml'
|
||||||
|
'3d5afcef6bf1c65dcd8cad3d1687f07c2cfbb15d360f41251e46f9eb8950baac .github/workflows/notification-platform.yml'
|
||||||
'64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml'
|
'64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml'
|
||||||
'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml'
|
'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.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."
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
+38
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<String, List<String>> 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<String> contentType,
|
||||||
|
Map<String, List<String>> headers,
|
||||||
|
byte[] body) {
|
||||||
|
return new CallbackRequest(
|
||||||
|
new ProviderId(provider),
|
||||||
|
new ProviderProfileId(profile),
|
||||||
|
externalUrl,
|
||||||
|
method,
|
||||||
|
contentType,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
clock.instant());
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<String> trustedProxies;
|
||||||
|
|
||||||
|
public ExternalRequestUrlResolver(Set<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<Void> 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<Void> onValidationFailure(CallbackValidationException failure) {
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<byte[]> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<ServerResponse> notificationCallbackRoutes(
|
||||||
|
NotificationCallbackWebFluxHandler handler) {
|
||||||
|
return new CallbackWebFluxRouter(handler).routes();
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<ServerResponse> routes() {
|
||||||
|
return RouterFunctions.route(
|
||||||
|
RequestPredicates.POST("/internal/notification/callbacks/{provider}/{profile}"),
|
||||||
|
handler::handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<ServerResponse> 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<String, List<String>> headers(ServerRequest request) {
|
||||||
|
Map<String, List<String>> 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<String> contentType(ServerRequest request) {
|
||||||
|
return request.headers().contentType().map(Object::toString);
|
||||||
|
}
|
||||||
|
}
|
||||||
+396
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<CallbackRequest> verified = new ArrayList<>();
|
||||||
|
private final List<String> 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<NormalizedProviderEvent> 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<String, String> tags) {
|
||||||
|
// Intentionally empty.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void record(String metricName, Map<String, String> tags, Duration value) {
|
||||||
|
// Intentionally empty.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void gauge(String metricName, Map<String, String> 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> T inWrite(java.util.function.Supplier<T> action) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> T inRootWrite(java.util.function.Supplier<T> action) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> T inRead(java.util.function.Supplier<T> action) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> T inNew(java.util.function.Supplier<T> 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<VerifiedProviderEvent> events) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ProviderEventRecord> 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<ProviderEventRecord> unmatched(int limit) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ProviderEventRecord> eventsForAttempt(DeliveryAttemptId attemptId) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,34 @@ dependencies {
|
|||||||
implementation 'org.springframework.boot:spring-boot-autoconfigure'
|
implementation 'org.springframework.boot:spring-boot-autoconfigure'
|
||||||
implementation 'org.springframework:spring-web' // Slack webhook client (RestClient)
|
implementation 'org.springframework:spring-web' // Slack webhook client (RestClient)
|
||||||
implementation 'org.slf4j:slf4j-api'
|
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'
|
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||||
|
|
||||||
|
testImplementation 'io.projectreactor:reactor-test'
|
||||||
}
|
}
|
||||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
# This is a Gradle generated file for dependency locking.
|
# This is a Gradle generated file for dependency locking.
|
||||||
# Manual edits can break the build and are not advised.
|
# Manual edits can break the build and are not advised.
|
||||||
# This file is expected to be part of source control.
|
# This file is expected to be part of source control.
|
||||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
|
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
|
||||||
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath
|
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath
|
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
com.fasterxml.jackson.core:jackson-annotations:2.20=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.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||||
com.github.kevinstern:software-and-algorithms:1.0=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.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.spotbugs:spotbugs:4.10.2=spotbugs
|
||||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=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.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||||
com.google.auto.value:auto-value-annotations:1.9=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.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.code.gson:gson:2.13.2=spotbugs
|
||||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
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.41.0=spotbugs
|
||||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
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.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
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.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
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.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-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
io.micrometer:micrometer-observation: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
|
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
jakarta.annotation:jakarta.annotation-api:3.0.0=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
|
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||||
jaxen:jaxen:2.0.0=spotbugs
|
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:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||||
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
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.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,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.commons:commons-text:1.3=checkstyle
|
||||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||||
org.apache.httpcomponents:httpcore:4.4.16=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-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-core:1.12.0=checkstyle
|
||||||
org.apache.maven.doxia:doxia-logging-api: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
|
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.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
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.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||||
org.codehaus.plexus:plexus-component-annotations:2.1.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-container-default:2.1.0=checkstyle
|
||||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||||
org.dom4j:dom4j:2.2.0=spotbugs
|
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.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
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.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-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=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.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath
|
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
|
||||||
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
|
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
|
||||||
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
|
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
|
||||||
org.osgi:org.osgi.service.serviceloader:1.0.0=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-analysis:9.10.1=spotbugs
|
||||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||||
org.ow2.asm:asm-tree: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.10.1=spotbugs
|
||||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
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.reflections:reflections:0.10.2=checkstyle
|
||||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
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-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
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-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
|
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-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-http-converter: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-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-resttestclient: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-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-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-jackson: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-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-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-tomcat: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-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-webmvc: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-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-test: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
|
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.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-aop:7.0.1=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-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-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-core: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-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-test:7.0.1=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-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-webmvc:7.0.1=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.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||||
org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath
|
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
|
tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
|
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
|
tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
empty=
|
empty=
|
||||||
|
|||||||
+41
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<TenantId> scope = actor.tenantId();
|
||||||
|
if (scope.isPresent() && !scope.get().equals(tenantId)) {
|
||||||
|
throw new AdminAccessDeniedException(NotificationAdminAuthority.SUPPRESS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+325
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<AdminOperationResult> 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<AdminOperationResult> 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<String> 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<AdminOperationResult> 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<AdminOperationResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+154
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<String, Provider> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A held concurrency slot for one provider attempt.
|
||||||
|
*
|
||||||
|
* <p>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();
|
||||||
|
}
|
||||||
+52
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<ProviderProfileId, ReconciliationCapability> capabilities;
|
||||||
|
private final ProviderRuntimeRegistry runtimes;
|
||||||
|
|
||||||
|
public CapabilityReconciliationGateway(
|
||||||
|
Map<ProviderProfileId, ReconciliationCapability> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<Channel, ProviderProfileId> profilesByChannel;
|
||||||
|
|
||||||
|
public ConfiguredRoutePlanner(Map<Channel, ProviderProfileId> profilesByChannel) {
|
||||||
|
this.profilesByChannel =
|
||||||
|
Map.copyOf(Objects.requireNonNull(profilesByChannel, "profilesByChannel"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<RouteCandidate> plan(
|
||||||
|
TenantId tenantId, RecipientSpec recipient, DeliveryStrategy strategy) {
|
||||||
|
Objects.requireNonNull(tenantId, "tenantId");
|
||||||
|
Objects.requireNonNull(recipient, "recipient");
|
||||||
|
Objects.requireNonNull(strategy, "strategy");
|
||||||
|
|
||||||
|
List<Channel> ordered =
|
||||||
|
switch (strategy) {
|
||||||
|
case ExplicitChannel explicit -> List.of(explicit.channel());
|
||||||
|
case OrderedFallback fallback -> fallback.channels();
|
||||||
|
};
|
||||||
|
|
||||||
|
List<RouteCandidate> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -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);
|
||||||
|
}
|
||||||
+19
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<RouteCandidate> routes) {
|
||||||
|
Objects.requireNonNull(routes, "routes");
|
||||||
|
List<Map<String, Object>> encoded = new ArrayList<>(routes.size());
|
||||||
|
for (RouteCandidate route : routes) {
|
||||||
|
Map<String, Object> 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<RouteCandidate> decode(String payload) {
|
||||||
|
Objects.requireNonNull(payload, "payload");
|
||||||
|
List<LinkedHashMap<String, Object>> raw =
|
||||||
|
NotificationJsonMapper.mapper()
|
||||||
|
.readValue(payload, new TypeReference<ArrayList<LinkedHashMap<String, Object>>>() {});
|
||||||
|
List<RouteCandidate> routes = new ArrayList<>(raw.size());
|
||||||
|
for (Map<String, Object> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<dev.caskeleton.application.notification.platform.api.RecipientDeliveryId> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -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<Channel, NotificationTemplateRenderer> renderers;
|
||||||
|
|
||||||
|
public MapTemplateRendererRegistry(List<NotificationTemplateRenderer> renderers) {
|
||||||
|
Objects.requireNonNull(renderers, "renderers");
|
||||||
|
Map<Channel, NotificationTemplateRenderer> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch runtime bounds.
|
||||||
|
*
|
||||||
|
* <p>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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+137
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<RecipientLease> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+136
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<ProviderRuntimeState> state;
|
||||||
|
private final AtomicReference<String> 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<String> 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.
|
||||||
|
*
|
||||||
|
* <p>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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
@@ -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<ProviderProfileId, ProviderRuntime> current = new ConcurrentHashMap<>();
|
||||||
|
private final Map<ProviderProfileId, CopyOnWriteArrayList<ProviderRuntime>> 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<ProviderRuntime> find(ProviderProfileId profileId) {
|
||||||
|
return Optional.ofNullable(current.get(profileId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swap in a new generation and start draining the old one.
|
||||||
|
*
|
||||||
|
* <p>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<ProviderRuntime> 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<ProviderRuntime> 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<ProviderRuntime> generations = draining.get(profileId);
|
||||||
|
if (generations == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
generations.removeIf(runtime -> runtime.activeAttempts() == 0);
|
||||||
|
if (generations.isEmpty()) {
|
||||||
|
draining.remove(profileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>The candidate is probed <em>before</em> 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.
|
||||||
|
*
|
||||||
|
* <p>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<ProviderRuntime> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<String, String> tags) {
|
||||||
|
guard.validate(tags);
|
||||||
|
log.info("metric={} kind=counter tags={}", metricName, ordered(tags));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void record(String metricName, Map<String, String> 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<String, String> tags, double value) {
|
||||||
|
guard.validate(tags);
|
||||||
|
log.info("metric={} kind=gauge value={} tags={}", metricName, value, ordered(tags));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> ordered(Map<String, String> tags) {
|
||||||
|
return new TreeMap<>(tags);
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<ProviderProfileId> monitoredProfiles;
|
||||||
|
|
||||||
|
public NotificationHealthReporter(
|
||||||
|
ProviderRuntimeRegistry runtimes, List<ProviderProfileId> monitoredProfiles) {
|
||||||
|
this.runtimes = Objects.requireNonNull(runtimes, "runtimes");
|
||||||
|
this.monitoredProfiles = List.copyOf(Objects.requireNonNull(monitoredProfiles, "profiles"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current snapshot. */
|
||||||
|
public NotificationHealthSnapshot snapshot() {
|
||||||
|
List<NotificationHealthSnapshot.ProviderHealth> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<ProviderHealth> providers, Map<String, Long> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<Duration> 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<Duration> retryAfter(Optional<String> headerValue) {
|
||||||
|
return headerValue.flatMap(
|
||||||
|
value -> {
|
||||||
|
try {
|
||||||
|
return Optional.of(Duration.ofSeconds(Long.parseLong(value.trim())));
|
||||||
|
} catch (NumberFormatException notSeconds) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -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<String> INVALID_TOKEN_REASONS =
|
||||||
|
Set.of("BadDeviceToken", "Unregistered", "DeviceTokenNotForTopic");
|
||||||
|
private static final Set<String> CONFIGURATION_REASONS =
|
||||||
|
Set.of("BadTopic", "TopicDisallowed", "BadCertificateEnvironment", "InvalidPushType");
|
||||||
|
|
||||||
|
/** Classify a non-2xx APNs response. */
|
||||||
|
public ProviderFailure classify(NotificationHttpResponse response) {
|
||||||
|
Optional<String> 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<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<String> authorizationSupplier;
|
||||||
|
|
||||||
|
public ApnsNotificationProviderAdapter(
|
||||||
|
NotificationHttpGateway gateway,
|
||||||
|
ApnsRequestMapper mapper,
|
||||||
|
ApnsFailureClassifier classifier,
|
||||||
|
ContactPointProtector protector,
|
||||||
|
Supplier<String> 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<Channel> 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<ProviderSubmissionResult> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<String> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
@@ -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<String, Object> 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<String, Object> payload = new LinkedHashMap<>();
|
||||||
|
payload.put("aps", aps);
|
||||||
|
payload.putAll(push.data());
|
||||||
|
|
||||||
|
Map<String, String> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<List<ProviderSubmissionResult>> submit(
|
||||||
|
List<ProviderSubmission> 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<Map<String, Object>> 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<ProviderSubmissionResult> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -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<Item> items) {
|
||||||
|
|
||||||
|
public FcmBatchResult {
|
||||||
|
items = List.copyOf(Objects.requireNonNull(items, "items"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One item result, aligned with the input index. */
|
||||||
|
public record Item(boolean success, Optional<String> messageId, Optional<String> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>{@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<Duration> retryAfter(Optional<String> headerValue) {
|
||||||
|
return headerValue.flatMap(
|
||||||
|
value -> {
|
||||||
|
try {
|
||||||
|
return Optional.of(Duration.ofSeconds(Long.parseLong(value.trim())));
|
||||||
|
} catch (NumberFormatException notSeconds) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -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<Map<String, Object>> messages);
|
||||||
|
}
|
||||||
+72
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<String, Object> 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<String, Object> 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<String, Object> 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<Duration> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<Channel> 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<ProviderSubmissionResult> submit(ProviderSubmission submission) {
|
||||||
|
Objects.requireNonNull(submission, "submission");
|
||||||
|
return coordinator.submit(List.of(submission)).thenApply(results -> results.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletionStage<List<ProviderSubmissionResult>> submitBatch(
|
||||||
|
List<ProviderSubmission> submissions) {
|
||||||
|
return coordinator.submit(submissions);
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -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");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>Redirects are never followed. A provider redirect would move a signed, credential-bearing
|
||||||
|
* request to a host the profile never approved.
|
||||||
|
*
|
||||||
|
* <p>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<String> 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<byte[]> 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.
|
||||||
|
*
|
||||||
|
* <p>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<String, List<String>> headers(Map<String, String> singleValued) {
|
||||||
|
return singleValued.entrySet().stream()
|
||||||
|
.collect(
|
||||||
|
java.util.stream.Collectors.toUnmodifiableMap(
|
||||||
|
Map.Entry::getKey, entry -> List.of(entry.getValue())));
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -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<String> LOOPBACK_HOSTS = Set.of("127.0.0.1", "::1", "localhost");
|
||||||
|
|
||||||
|
private NotificationEndpoints() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require TLS, except on the loopback interface.
|
||||||
|
*
|
||||||
|
* <p>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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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);
|
||||||
|
}
|
||||||
+64
@@ -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<String, List<String>> 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
|
||||||
|
+ "]";
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -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<String, List<String>> 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<String> header(String name) {
|
||||||
|
List<String> 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 + "]";
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>{@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<String, String> 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<String, String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<String, String> 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<NormalizedProviderEvent> normalize(VerifiedCallback callback) {
|
||||||
|
Objects.requireNonNull(callback, "callback");
|
||||||
|
Map<String, String> 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<String, String> flatten(String body) {
|
||||||
|
JsonNode root = NotificationJsonMapper.mapper().readTree(body);
|
||||||
|
Map<String, String> envelope = new LinkedHashMap<>();
|
||||||
|
root.properties()
|
||||||
|
.forEach(
|
||||||
|
property -> {
|
||||||
|
JsonNode value = property.getValue();
|
||||||
|
if (value != null && value.isValueNode()) {
|
||||||
|
envelope.put(property.getKey(), value.asString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+101
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<NormalizedProviderEvent> normalize(String payload) {
|
||||||
|
JsonNode root = NotificationJsonMapper.mapper().readTree(payload);
|
||||||
|
String type = text(root, "eventType").orElse(text(root, "notificationType").orElse("Unknown"));
|
||||||
|
Optional<String> messageId =
|
||||||
|
Optional.ofNullable(root.get("mail")).flatMap(mail -> text(mail, "messageId"));
|
||||||
|
Optional<Instant> occurredAt = timestamp(root, type);
|
||||||
|
|
||||||
|
List<NormalizedProviderEvent> 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<String> messageId, Optional<Instant> 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<String> messageId,
|
||||||
|
Optional<Instant> occurredAt,
|
||||||
|
Map<String, String> attributes) {
|
||||||
|
return new NormalizedProviderEvent(
|
||||||
|
type, nativeType, Optional.empty(), messageId, occurredAt, attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<String> text(JsonNode node, String field) {
|
||||||
|
JsonNode value = node.get(field);
|
||||||
|
return value == null || value.isNull() ? Optional.empty() : Optional.of(value.asString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<Instant> 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
+134
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>{@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.
|
||||||
|
*
|
||||||
|
* <p>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<Channel> 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<ProviderSubmissionResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -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<String> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
@@ -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<String, Object> simple = new LinkedHashMap<>();
|
||||||
|
simple.put("Subject", Map.of("Data", email.subject(), "Charset", "UTF-8"));
|
||||||
|
Map<String, Object> 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<String, Object> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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);
|
||||||
|
}
|
||||||
+108
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<String> NOTIFICATION_FIELDS =
|
||||||
|
Set.of("Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type");
|
||||||
|
private static final Set<String> 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<String, String> 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<String, String> envelope) {
|
||||||
|
Set<String> fields =
|
||||||
|
"SubscriptionConfirmation".equals(envelope.get("Type"))
|
||||||
|
|| "UnsubscribeConfirmation".equals(envelope.get("Type"))
|
||||||
|
? SUBSCRIPTION_FIELDS
|
||||||
|
: NOTIFICATION_FIELDS;
|
||||||
|
|
||||||
|
Map<String, String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
|
||||||
|
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single SMTP send operation.
|
||||||
|
*
|
||||||
|
* <p>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);
|
||||||
|
}
|
||||||
+30
@@ -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<Integer> replyCode;
|
||||||
|
private final boolean dataCommitted;
|
||||||
|
|
||||||
|
public SmtpDispatchException(
|
||||||
|
String reasonCode, Optional<Integer> 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<Integer> replyCode() {
|
||||||
|
return replyCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the message body had already been transmitted when the failure happened. */
|
||||||
|
public boolean dataCommitted() {
|
||||||
|
return dataCommitted;
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<Integer> INVALID_RECIPIENT_CODES = Set.of(550, 551, 553, 511);
|
||||||
|
|
||||||
|
/** Classify a failed send. */
|
||||||
|
public ProviderSubmissionResult classify(SmtpDispatchException failure, Duration elapsed) {
|
||||||
|
Optional<Integer> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+95
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<ResolvedAttachment> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+102
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<Channel> 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<ProviderSubmissionResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMTP profile.
|
||||||
|
*
|
||||||
|
* <p>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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -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<String, String> 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<NormalizedProviderEvent> normalize(VerifiedCallback callback) {
|
||||||
|
Objects.requireNonNull(callback, "callback");
|
||||||
|
Optional<Instant> occurredAt = Optional.of(callback.request().receivedAt());
|
||||||
|
return List.of(normalizer.normalize(callback.canonicalParameters(), occurredAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> parseForm(byte[] body) {
|
||||||
|
Map<String, String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -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<Integer> INVALID_NUMBER_CODES =
|
||||||
|
Set.of(21211, 21214, 21610, 21612, 21614);
|
||||||
|
|
||||||
|
/** Classify a non-2xx Twilio response. */
|
||||||
|
public ProviderFailure classify(NotificationHttpResponse response) {
|
||||||
|
Optional<Integer> 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<Integer> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>{@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<String> messagingServiceSid,
|
||||||
|
Optional<String> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+138
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<ReconciliationResult> reconcile(DeliveryAttemptSnapshot attempt) {
|
||||||
|
Objects.requireNonNull(attempt, "attempt");
|
||||||
|
|
||||||
|
Optional<String> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -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<String, String> 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<String, String> form) {
|
||||||
|
return form.entrySet().stream()
|
||||||
|
.map(
|
||||||
|
entry ->
|
||||||
|
URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)
|
||||||
|
+ "="
|
||||||
|
+ URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8))
|
||||||
|
.collect(Collectors.joining("&"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user