protocol_droid/application/a2a/push_notification.rs
1//! A2A push-notification delivery — the use case the wire codec feeds.
2//!
3//! # Why a port and not a function
4//!
5//! Delivering a push notification is two things at once:
6//!
7//! - A *policy* check (the URL filter, OWASP API7) — pure, testable, the
8//! same on every deployment.
9//! - A *delivery* step (open a socket, POST a body, retry on failure) —
10//! infrastructure, swappable, deployment-specific.
11//!
12//! Mixing them in one trait would put a `reqwest::Client` into a port a
13//! domain-logic handler has to depend on. Splitting them lets the policy
14//! stay in `domain/` and `application/`, and the delivery live in
15//! `adapters/secondary/a2a/`. The `PushNotificationDelivery` port here is
16//! the integration point — the wire codec hands the config and the payload
17//! in, the port returns success or a typed refusal, and the rest of the
18//! handler is none the wiser about which HTTP client is on the other side.
19//!
20//! # Idempotency
21//!
22//! The A2A spec does not name a delivery-attempt header (e.g. MCP's
23//! `Mcp-Session-Id`); what it does say is that the same notification may
24//! be delivered more than once when a previous attempt did not
25//! acknowledge. The [`WebhookUrl::idempotency_key`] is the consumer's
26//! chance to dedupe: implementations should record `(idempotency_key,
27//! response)` and return the recorded response on retry.
28
29use std::sync::Arc;
30
31use url::Url;
32
33use crate::application::a2a::UrlFilter;
34use crate::domain::a2a::A2aVersion;
35use crate::domain::idempotency::IdempotencyKey;
36
37/// The push-notification URL a client registered, after URL filter approval.
38///
39/// The newtype exists so the wire codec can parse a `Url` once, hand the
40/// filtered value to the use case, and have the type system prove the URL
41/// was checked before delivery. A `Url` that arrives at the delivery port
42/// without going through [`WebhookUrl::new`] is a delivery path that
43/// bypassed the SSRF guard.
44#[derive(Debug, Clone)]
45pub struct WebhookUrl(Url);
46
47impl WebhookUrl {
48 /// Wraps a URL that has already passed the [`UrlFilter::check_at_config`]
49 /// gate.
50 ///
51 /// # Errors
52 ///
53 /// The only thing this constructor can refuse is a URL that the filter
54 /// would refuse — [`UrlFilter::check_at_config`] is the gate, not this
55 /// constructor, but the constructor delegates the check to the
56 /// provided filter so the wire codec has one call to make and one
57 /// error to handle.
58 pub fn new(
59 filter: &dyn UrlFilter,
60 url: Url,
61 version: A2aVersion,
62 ) -> Result<Self, super::FilterDenial> {
63 filter.check_at_config(&url, version)?;
64 Ok(Self(url))
65 }
66
67 /// The inner URL, for an adapter that has to make the request.
68 #[must_use]
69 pub const fn as_url(&self) -> &Url {
70 &self.0
71 }
72
73 /// The key the delivery adapter uses to dedupe retried deliveries.
74 ///
75 /// `WebhookUrl::idempotency_key(url)` is a pure function of the URL —
76 /// the same URL produces the same key on every call. The delivery
77 /// adapter records `(key, response)` and returns the recorded
78 /// response on retry, so a network blip that lost the `2xx` reply
79 /// does not produce a duplicate notification.
80 #[must_use]
81 pub fn idempotency_key(&self) -> IdempotencyKey {
82 // The URL is the deduplication scope. Two delivery attempts for
83 // the same URL on the same task are the same notification; the
84 // adapter's `(task_id, url)` record catches cross-task duplicates
85 // as a side effect of the URL being unique per task in the spec.
86 IdempotencyKey::new(self.0.as_str())
87 }
88}
89
90/// A `PushNotificationConfig` the A2A client registered, post-validation.
91///
92/// The wire codec decodes the JSON, the URL filter clears the URL, and
93/// the resulting `NotificationConfig` is what the delivery port sees.
94#[derive(Debug, Clone)]
95pub struct NotificationConfig {
96 /// The webhook the server POSTs to.
97 pub webhook: WebhookUrl,
98 /// An opaque token the server includes in the `X-A2A-Notification-Token`
99 /// header so the receiver can authenticate the delivery. Optional in
100 /// the A2A spec; a receiver without a token is a receiver that does
101 /// not need to authenticate, and the spec does not require one.
102 pub token: Option<Arc<str>>,
103}
104
105/// Failure modes for a delivery attempt, in priority order.
106///
107/// `Refused` (the filter denied the URL) is distinct from
108/// `TransientFailure` (the filter accepted the URL but the network
109/// refused) so the handler can choose to surface one and silently retry
110/// the other. The split is what makes the difference between "the client
111/// is malicious" and "the network is bad" a typed answer.
112#[derive(Debug, thiserror::Error)]
113pub enum NotificationConfigError {
114 /// The URL was rejected at config time. Surfacing this lets the
115 /// handler answer the client with the typed refusal; retrying will
116 /// not help.
117 #[error("the webhook URL was rejected: {0}")]
118 Refused(#[from] super::url_filter::FilterDenial),
119 /// The URL was accepted but the
120 /// delivery step failed (network,
121 /// timeout, non-2xx, or a
122 /// redirect loop). The handler
123 /// decides whether to retry;
124 /// the port reports the failure
125 /// as a structured reason.
126 #[error("transient delivery failure: {reason}")]
127 TransientFailure {
128 /// A human-readable reason for the failure.
129 reason: String,
130 },
131}
132
133/// The port the push-notification handler depends on.
134///
135/// An implementation is `HttpPushNotificationDelivery` (in
136/// `adapters/secondary/a2a/`); a test implementation is a closure or a
137/// `Vec<NotificationConfig>` in-memory recorder. The handler is none the
138/// wiser.
139pub trait PushNotificationDelivery: Send + Sync {
140 /// Delivers one notification.
141 ///
142 /// # Errors
143 ///
144 /// - [`NotificationConfigError::Refused`] if the URL filter rejects
145 /// the URL at delivery time (a DNS-rebind to a denied IP, for
146 /// example).
147 /// - A network / timeout / non-2xx error if the POST failed and a
148 /// retry is appropriate. The handler maps this to its own retry
149 /// policy; the port deliberately returns no concrete type here so
150 /// an adapter can carry a `reqwest::Error` (or a
151 /// `tokio::io::Error`, or a custom error) without forcing the
152 /// handler to name a third-party error type.
153 fn deliver(
154 &self,
155 config: &NotificationConfig,
156 body: &[u8],
157 ) -> impl std::future::Future<Output = Result<(), NotificationConfigError>> + Send;
158}
159
160#[cfg(test)]
161mod tests {
162 #![expect(
163 clippy::expect_used,
164 reason = "tests assert on fallible calls with named failure messages; propagating errors would replace a clear failure with noise"
165 )]
166
167 use std::str::FromStr;
168
169 use super::{NotificationConfig, NotificationConfigError, WebhookUrl};
170 use crate::adapters::secondary::a2a::SsrfDenylist;
171 use crate::application::a2a::PushNotificationDelivery;
172 use crate::domain::a2a::A2aVersion;
173
174 type TestError = Box<dyn std::error::Error>;
175
176 const V1_0: A2aVersion = A2aVersion::V1_0;
177
178 #[test]
179 fn idempotency_key_is_deterministic() -> Result<(), TestError> {
180 // The same URL must produce the same key on every call, or the
181 // delivery adapter's retry-dedup record misses a duplicate.
182 let url: url::Url = "https://example.com/hook".parse()?;
183 let a = WebhookUrl::new(&SsrfDenylist::FILTER, url.clone(), V1_0)
184 .expect("the public HTTPS URL must be accepted by the default filter");
185 let b = WebhookUrl::new(&SsrfDenylist::FILTER, url, V1_0)
186 .expect("the same URL must be accepted on a second call");
187 assert_eq!(
188 a.idempotency_key().as_str(),
189 b.idempotency_key().as_str(),
190 "the same URL must produce the same key on every call"
191 );
192
193 // A different URL must produce a different key, or the adapter
194 // would mistake one notification for another.
195 let other: url::Url = "https://example.com/hook?x=1".parse()?;
196 let c = WebhookUrl::new(&SsrfDenylist::FILTER, other, V1_0)
197 .expect("a different public URL must be accepted");
198 assert_ne!(
199 a.idempotency_key().as_str(),
200 c.idempotency_key().as_str(),
201 "different URLs must produce different keys"
202 );
203 Ok(())
204 }
205
206 #[test]
207 fn webhook_url_rejects_a_filter_denied_url() {
208 // 127.0.0.1 is on the default denylist. The constructor
209 // delegates to `check_at_config`, so a loopback URL cannot
210 // become a `WebhookUrl`.
211 let url = url::Url::from_str("http://127.0.0.1/hook")
212 .expect("the test fixture must parse as a URL");
213 let err = WebhookUrl::new(&SsrfDenylist::FILTER, url, V1_0)
214 .expect_err("a loopback URL must be denied at the constructor");
215 let matched = matches!(
216 err,
217 crate::application::a2a::FilterDenial::DeniedIpLiteral { .. }
218 );
219 assert!(matched, "denial was {err:?}");
220 }
221
222 #[test]
223 fn notification_config_wraps_a_filtered_webhook() -> Result<(), TestError> {
224 // The wire codec decodes a `PushNotificationConfig`, runs the
225 // URL through the filter, and hands the application layer a
226 // `NotificationConfig`. The path through the public API is
227 // small enough to exercise in a single test.
228 let url: url::Url = "https://example.com/hook".parse()?;
229 let webhook = WebhookUrl::new(&SsrfDenylist::FILTER, url, V1_0)?;
230 let config = NotificationConfig {
231 webhook,
232 token: None,
233 };
234 assert_eq!(config.webhook.as_url().scheme(), "https");
235 assert!(config.token.is_none());
236 Ok(())
237 }
238
239 #[test]
240 fn notification_config_error_preserves_the_filter_denial() -> Result<(), TestError> {
241 // The `Refused` variant wraps the typed `FilterDenial` so a
242 // handler can surface the *class* of refusal (loopback, RFC
243 // 1918, scheme, etc.) rather than collapsing every config
244 // error into "the URL was rejected".
245 use crate::application::a2a::FilterDenial;
246 let denial = FilterDenial::DeniedIpLiteral {
247 literal: "10.0.0.1".to_owned(),
248 };
249 let err = NotificationConfigError::Refused(denial.clone());
250 let display = format!("{err}");
251 assert!(display.contains("rejected"), "display was {display}");
252 match err {
253 NotificationConfigError::Refused(d) => assert_eq!(d, denial),
254 NotificationConfigError::TransientFailure { reason } => {
255 return Err(format!("expected Refused, got TransientFailure({reason})").into());
256 }
257 }
258 Ok(())
259 }
260
261 /// A recording impl of `PushNotificationDelivery` for the trait test.
262 ///
263 /// The point of the test is to prove the trait is consumable by a
264 /// downstream impl — a closed test that does not name the trait
265 /// would leave the public surface unexercised.
266 #[derive(Debug, Default)]
267 struct RecordingDelivery {
268 delivered: std::sync::Mutex<Vec<Vec<u8>>>,
269 }
270
271 impl PushNotificationDelivery for RecordingDelivery {
272 // The recording impl has nothing to await. Returning
273 // `std::future::ready` matches the trait's RPITIT signature
274 // (`fn deliver(&self) -> impl Future<...>`) without the
275 // `async fn` keyword, so `clippy::unused_async` does not
276 // fire and the test stays an honest statement of what a
277 // sync impl looks like.
278 fn deliver(
279 &self,
280 _config: &NotificationConfig,
281 body: &[u8],
282 ) -> impl std::future::Future<Output = Result<(), NotificationConfigError>> + Send {
283 let outcome = self.delivered.lock().map_or_else(
284 |_| {
285 Err(NotificationConfigError::Refused(
286 crate::application::a2a::FilterDenial::Malformed,
287 ))
288 },
289 |mut v| {
290 v.push(body.to_vec());
291 Ok(())
292 },
293 );
294 std::future::ready(outcome)
295 }
296 }
297
298 #[test]
299 fn push_notification_delivery_is_implementable() -> Result<(), TestError> {
300 // The trait has the right shape: a downstream consumer can
301 // write a struct, implement it, and have the async method
302 // call through. This pins the trait's signature. The trait
303 // uses native RPITIT (return-position `impl Trait`), so a
304 // `Box<dyn PushNotificationDelivery>` is not the right shape
305 // here — the test exercises the concrete impl, which is the
306 // form a hot-path port uses at the call site.
307 let delivery = RecordingDelivery::default();
308 let url: url::Url = "https://example.com/hook".parse()?;
309 let webhook = WebhookUrl::new(&SsrfDenylist::FILTER, url, V1_0)?;
310 let config = NotificationConfig {
311 webhook,
312 token: None,
313 };
314 // Drive the trait's `deliver` method through a small executor
315 // so the test exercises the impl, not just the type. The dev
316 // dependency on `tokio` is for the test runtime; production
317 // code never depends on a particular executor.
318 let runtime = tokio::runtime::Builder::new_current_thread()
319 .enable_all()
320 .build()
321 .map_err(|e| -> Box<dyn std::error::Error> { format!("{e:?}").into() })?;
322 let body = b"hello";
323 let outcome: Result<(), NotificationConfigError> =
324 runtime.block_on(delivery.deliver(&config, body));
325 outcome.map_err(|e| -> Box<dyn std::error::Error> { format!("{e:?}").into() })?;
326 let recorded = delivery
327 .delivered
328 .lock()
329 .map_err(|e| -> Box<dyn std::error::Error> { format!("{e:?}").into() })?
330 .len();
331 assert_eq!(recorded, 1);
332 Ok(())
333 }
334}