Skip to main content

bridge/
push.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6use tokio::sync::RwLock;
7use tracing::{debug, error, info, warn};
8
9/// Cached JWT token with expiry tracking.
10struct JwtCache {
11    token: String,
12    expires_at: Instant,
13}
14
15/// Push relay client for forwarding device tokens and sending push notifications
16/// via the centralized push relay service (Cloudflare Worker).
17///
18/// The bridge never holds APNs/FCM credentials. It only knows the relay URL
19/// and authenticates via a short-lived RS256 JWT fetched from the token service.
20#[derive(Clone)]
21pub struct PushRelayClient {
22    relay_url: String,
23    http_client: reqwest::Client,
24    /// Per-token debounce tracking: token → last notification time
25    debounce: Arc<RwLock<HashMap<String, Instant>>>,
26    /// Debounce cooldown duration (default 30s)
27    cooldown: Duration,
28    /// JWT auth — set by with_jwt_credentials()
29    token_url: Option<String>,
30    client_id: Option<String>,
31    client_secret: Option<String>,
32    jwt_cache: Arc<RwLock<Option<JwtCache>>>,
33}
34
35/// Request to register a device token with the relay
36#[derive(Debug, Serialize)]
37struct RegisterRequest {
38    device_token: String,
39    platform: String,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    bundle_id: Option<String>,
42}
43
44/// Request to unregister a device token
45#[derive(Debug, Serialize)]
46struct UnregisterRequest {
47    device_token: String,
48}
49
50/// Request to send a push notification
51#[derive(Debug, Serialize)]
52struct PushRequest {
53    title: String,
54    body: String,
55}
56
57/// Token service response for POST /token
58#[derive(Debug, Deserialize)]
59struct TokenResponse {
60    access_token: String,
61    expires_in: u64,
62}
63
64/// Push relay API response
65#[derive(Debug, Deserialize)]
66struct RelayResponse {
67    ok: bool,
68    #[serde(default)]
69    error: Option<String>,
70    #[serde(default)]
71    message: Option<String>,
72}
73
74impl PushRelayClient {
75    /// Create a new push relay client.
76    ///
77    /// - `relay_url`: Base URL of the push relay (e.g., "https://push.aptove.com")
78    /// - `_relay_token`: Kept for API compatibility; unused when JWT credentials are set
79    pub fn new(relay_url: String, _relay_token: String) -> Self {
80        let http_client = reqwest::Client::builder()
81            .timeout(Duration::from_secs(10))
82            .build()
83            .expect("Failed to create HTTP client");
84
85        Self {
86            relay_url: relay_url.trim_end_matches('/').to_string(),
87            http_client,
88            debounce: Arc::new(RwLock::new(HashMap::new())),
89            cooldown: Duration::from_secs(30),
90            token_url: None,
91            client_id: None,
92            client_secret: None,
93            jwt_cache: Arc::new(RwLock::new(None)),
94        }
95    }
96
97    /// Configure JWT authentication credentials from the token service.
98    pub fn with_jwt_credentials(
99        mut self,
100        token_url: String,
101        client_id: String,
102        client_secret: String,
103    ) -> Self {
104        self.token_url = Some(token_url);
105        self.client_id = Some(client_id);
106        self.client_secret = Some(client_secret);
107        self
108    }
109
110    /// Fetch (or return cached) a JWT from the token service.
111    ///
112    /// The token is cached until it has < 60 seconds remaining.
113    async fn get_jwt(&self) -> Result<String> {
114        // Fast path: return cached token if still valid
115        {
116            let cache = self.jwt_cache.read().await;
117            if let Some(ref c) = *cache {
118                if c.expires_at > Instant::now() + Duration::from_secs(60) {
119                    return Ok(c.token.clone());
120                }
121            }
122        }
123
124        let token_url = self
125            .token_url
126            .as_deref()
127            .context("token_url not configured")?;
128        let client_id = self
129            .client_id
130            .as_deref()
131            .context("client_id not configured")?;
132        let client_secret = self
133            .client_secret
134            .as_deref()
135            .context("client_secret not configured")?;
136
137        let url = format!("{}/token", token_url.trim_end_matches('/'));
138        let body = serde_json::json!({
139            "client_id": client_id,
140            "client_secret": client_secret,
141        });
142
143        debug!("Fetching JWT from token service: {}", url);
144        let res = self
145            .http_client
146            .post(&url)
147            .json(&body)
148            .send()
149            .await
150            .context("Failed to contact token service")?;
151
152        let status = res.status();
153        if !status.is_success() {
154            anyhow::bail!("Token service returned HTTP {}", status);
155        }
156
157        let token_resp: TokenResponse = res
158            .json()
159            .await
160            .context("Failed to parse token service response")?;
161
162        let expires_at = Instant::now()
163            + Duration::from_secs(token_resp.expires_in.saturating_sub(60));
164
165        let mut cache = self.jwt_cache.write().await;
166        *cache = Some(JwtCache {
167            token: token_resp.access_token.clone(),
168            expires_at,
169        });
170
171        debug!("JWT fetched, expires in {}s", token_resp.expires_in);
172        Ok(token_resp.access_token)
173    }
174
175    /// Build an HTTP request with JWT Authorization header.
176    async fn authorized_request(
177        &self,
178        builder: reqwest::RequestBuilder,
179    ) -> Result<reqwest::RequestBuilder> {
180        let jwt = self.get_jwt().await?;
181        Ok(builder.header("Authorization", format!("Bearer {}", jwt)))
182    }
183
184    /// Register a device token with the push relay.
185    ///
186    /// Called when the mobile app sends `bridge/registerPushToken` over WebSocket.
187    pub async fn register_device(
188        &self,
189        device_token: &str,
190        platform: &str,
191        bundle_id: Option<&str>,
192    ) -> Result<()> {
193        let url = format!("{}/register", self.relay_url);
194        let body = RegisterRequest {
195            device_token: device_token.to_string(),
196            platform: platform.to_string(),
197            bundle_id: bundle_id.map(|s| s.to_string()),
198        };
199
200        info!("📱 Registering {} device token with push relay", platform);
201        debug!("Push relay URL: {}", url);
202
203        let builder = self.http_client.post(&url).json(&body);
204        let builder = self.authorized_request(builder).await?;
205        let res = builder
206            .send()
207            .await
208            .context("Failed to contact push relay for registration")?;
209
210        let status = res.status();
211        let response: RelayResponse = res
212            .json()
213            .await
214            .context("Failed to parse push relay response")?;
215
216        if response.ok {
217            info!("✅ Device token registered with push relay");
218            Ok(())
219        } else {
220            let err_msg = response
221                .error
222                .or(response.message)
223                .unwrap_or_else(|| format!("HTTP {}", status));
224            error!("❌ Push relay registration failed: {}", err_msg);
225            anyhow::bail!("Push relay registration failed: {}", err_msg)
226        }
227    }
228
229    /// Unregister a device token from the push relay.
230    pub async fn unregister_device(&self, device_token: &str) -> Result<()> {
231        let url = format!("{}/register", self.relay_url);
232        let body = UnregisterRequest {
233            device_token: device_token.to_string(),
234        };
235
236        info!("📱 Unregistering device token from push relay");
237
238        let builder = self.http_client.delete(&url).json(&body);
239        let builder = self.authorized_request(builder).await?;
240        let res = builder
241            .send()
242            .await
243            .context("Failed to contact push relay for unregistration")?;
244
245        let response: RelayResponse = res
246            .json()
247            .await
248            .context("Failed to parse push relay response")?;
249
250        if response.ok {
251            info!("✅ Device token unregistered from push relay");
252        }
253        Ok(())
254    }
255
256    /// Send a push notification via the relay.
257    ///
258    /// Includes per-agent debounce: if a notification was sent within the
259    /// cooldown window (default 30s), the new one is silently dropped.
260    ///
261    /// The notification content is fixed ("Your agent has new activity")
262    /// to prevent leaking agent response content.
263    pub async fn notify(&self, agent_name: &str) -> Result<bool> {
264        // Use client_id as debounce key (unique per bridge identity)
265        let debounce_key = self
266            .client_id
267            .clone()
268            .unwrap_or_else(|| self.relay_url.clone());
269
270        // Debounce check
271        {
272            let debounce = self.debounce.read().await;
273            if let Some(last) = debounce.get(&debounce_key) {
274                if last.elapsed() < self.cooldown {
275                    debug!(
276                        "Push notification throttled ({}s remaining)",
277                        (self.cooldown - last.elapsed()).as_secs()
278                    );
279                    return Ok(false);
280                }
281            }
282        }
283
284        // Update debounce timestamp
285        {
286            let mut debounce = self.debounce.write().await;
287            debounce.insert(debounce_key, Instant::now());
288        }
289
290        let url = format!("{}/push", self.relay_url);
291        let body = PushRequest {
292            title: agent_name.to_string(),
293            body: "Your agent has new activity".to_string(),
294        };
295
296        info!("🔔 Sending push notification via relay for agent '{}'", agent_name);
297
298        let builder = self.http_client.post(&url).json(&body);
299        let builder = match self.authorized_request(builder).await {
300            Ok(b) => b,
301            Err(e) => {
302                warn!("⚠️  Failed to get JWT for push notification: {}", e);
303                return Ok(false);
304            }
305        };
306
307        let res = builder
308            .send()
309            .await
310            .context("Failed to contact push relay for notification")?;
311
312        let status = res.status();
313        let response: RelayResponse = res
314            .json()
315            .await
316            .context("Failed to parse push relay response")?;
317
318        if response.ok {
319            info!("✅ Push notification sent via relay");
320            Ok(true)
321        } else {
322            let err_msg = response
323                .error
324                .or(response.message)
325                .unwrap_or_else(|| format!("HTTP {}", status));
326            warn!("⚠️  Push relay notification failed: {}", err_msg);
327            Ok(false)
328        }
329    }
330}