Skip to main content

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}