runbeam_sdk/runbeam_api/
token_storage.rs

1use crate::storage::{StorageBackend, StorageError};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4
5/// Path where machine tokens are stored relative to storage root
6const TOKEN_STORAGE_PATH: &str = "runbeam/auth.json";
7
8/// Machine-scoped token for Runbeam Cloud API authentication
9///
10/// This token is issued by Runbeam Cloud and allows the gateway to make
11/// autonomous API calls without user intervention. It has a 30-day expiry.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct MachineToken {
14    /// The machine token string
15    pub machine_token: String,
16    /// When the token expires (ISO 8601 format)
17    pub expires_at: String,
18    /// Gateway ID
19    pub gateway_id: String,
20    /// Gateway code (instance ID)
21    pub gateway_code: String,
22    /// Token abilities/permissions
23    #[serde(default)]
24    pub abilities: Vec<String>,
25    /// When this token was issued/stored (ISO 8601 format)
26    pub issued_at: String,
27}
28
29impl MachineToken {
30    /// Create a new machine token
31    pub fn new(
32        machine_token: String,
33        expires_at: String,
34        gateway_id: String,
35        gateway_code: String,
36        abilities: Vec<String>,
37    ) -> Self {
38        let issued_at = Utc::now().to_rfc3339();
39
40        Self {
41            machine_token,
42            expires_at,
43            gateway_id,
44            gateway_code,
45            abilities,
46            issued_at,
47        }
48    }
49
50    /// Check if the token has expired
51    pub fn is_expired(&self) -> bool {
52        // Parse the expiry timestamp
53        match DateTime::parse_from_rfc3339(&self.expires_at) {
54            Ok(expiry) => {
55                let now = Utc::now();
56                expiry.with_timezone(&Utc) < now
57            }
58            Err(e) => {
59                tracing::warn!("Failed to parse token expiry date: {}", e);
60                // If we can't parse the date, consider it expired for safety
61                true
62            }
63        }
64    }
65
66    /// Check if the token is still valid (not expired)
67    pub fn is_valid(&self) -> bool {
68        !self.is_expired()
69    }
70}
71
72/// Save a machine token to storage
73///
74/// Stores the token at `runbeam/auth.json` relative to the storage backend root.
75///
76/// # Arguments
77///
78/// * `storage` - The storage backend to use
79/// * `token` - The machine token to save
80///
81/// # Returns
82///
83/// Returns `Ok(())` if the token was saved successfully, or `Err(StorageError)`
84/// if the operation failed.
85pub async fn save_token(
86    storage: &dyn StorageBackend,
87    token: &MachineToken,
88) -> Result<(), StorageError> {
89    tracing::debug!("Saving machine token for gateway: {}", token.gateway_code);
90
91    // Serialize token to JSON
92    let json = serde_json::to_vec_pretty(&token).map_err(|e| {
93        tracing::error!("Failed to serialize machine token: {}", e);
94        StorageError::Config(format!("JSON serialization failed: {}", e))
95    })?;
96
97    // Write to storage
98    storage.write_file_str(TOKEN_STORAGE_PATH, &json).await?;
99
100    tracing::info!(
101        "Machine token saved successfully: gateway_id={}, expires_at={}",
102        token.gateway_id,
103        token.expires_at
104    );
105
106    Ok(())
107}
108
109/// Load a machine token from storage
110///
111/// Attempts to load the token from `runbeam/auth.json`. Returns `None` if the
112/// file doesn't exist.
113///
114/// # Arguments
115///
116/// * `storage` - The storage backend to use
117///
118/// # Returns
119///
120/// Returns `Ok(Some(token))` if a token was found and loaded successfully,
121/// `Ok(None)` if no token file exists, or `Err(StorageError)` if loading failed.
122pub async fn load_token(
123    storage: &dyn StorageBackend,
124) -> Result<Option<MachineToken>, StorageError> {
125    tracing::debug!("Loading machine token from storage");
126
127    // Check if token file exists
128    if !storage.exists_str(TOKEN_STORAGE_PATH) {
129        tracing::debug!("No machine token file found");
130        return Ok(None);
131    }
132
133    // Read token file
134    let json = storage.read_file_str(TOKEN_STORAGE_PATH).await?;
135
136    // Deserialize token
137    let token: MachineToken = serde_json::from_slice(&json).map_err(|e| {
138        tracing::error!("Failed to deserialize machine token: {}", e);
139        StorageError::Config(format!("JSON deserialization failed: {}", e))
140    })?;
141
142    tracing::debug!(
143        "Machine token loaded: gateway_id={}, expires_at={}, valid={}",
144        token.gateway_id,
145        token.expires_at,
146        token.is_valid()
147    );
148
149    Ok(Some(token))
150}
151
152/// Clear the machine token from storage
153///
154/// Removes the token file at `runbeam/auth.json` if it exists.
155///
156/// # Arguments
157///
158/// * `storage` - The storage backend to use
159///
160/// # Returns
161///
162/// Returns `Ok(())` if the token was cleared successfully or didn't exist,
163/// or `Err(StorageError)` if the operation failed.
164pub async fn clear_token(storage: &dyn StorageBackend) -> Result<(), StorageError> {
165    tracing::debug!("Clearing machine token from storage");
166
167    // Check if token file exists
168    if !storage.exists_str(TOKEN_STORAGE_PATH) {
169        tracing::debug!("No machine token file to clear");
170        return Ok(());
171    }
172
173    // Remove the token file
174    storage.remove_str(TOKEN_STORAGE_PATH).await?;
175
176    tracing::info!("Machine token cleared successfully");
177
178    Ok(())
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::storage::FilesystemStorage;
185    use tempfile::TempDir;
186
187    #[test]
188    fn test_machine_token_creation() {
189        let token = MachineToken::new(
190            "test_token".to_string(),
191            "2025-12-31T23:59:59Z".to_string(),
192            "gw123".to_string(),
193            "gateway-code-123".to_string(),
194            vec!["harmony:send".to_string(), "harmony:receive".to_string()],
195        );
196
197        assert_eq!(token.machine_token, "test_token");
198        assert_eq!(token.gateway_id, "gw123");
199        assert_eq!(token.gateway_code, "gateway-code-123");
200        assert_eq!(token.abilities.len(), 2);
201        assert!(!token.issued_at.is_empty());
202    }
203
204    #[test]
205    fn test_machine_token_is_expired() {
206        // Expired token (year 2020)
207        let expired_token = MachineToken::new(
208            "test_token".to_string(),
209            "2020-01-01T00:00:00Z".to_string(),
210            "gw123".to_string(),
211            "gateway-code-123".to_string(),
212            vec![],
213        );
214        assert!(expired_token.is_expired());
215        assert!(!expired_token.is_valid());
216
217        // Valid token (far future)
218        let valid_token = MachineToken::new(
219            "test_token".to_string(),
220            "2099-12-31T23:59:59Z".to_string(),
221            "gw123".to_string(),
222            "gateway-code-123".to_string(),
223            vec![],
224        );
225        assert!(!valid_token.is_expired());
226        assert!(valid_token.is_valid());
227    }
228
229    #[test]
230    fn test_machine_token_serialization() {
231        let token = MachineToken::new(
232            "test_token".to_string(),
233            "2025-12-31T23:59:59Z".to_string(),
234            "gw123".to_string(),
235            "gateway-code-123".to_string(),
236            vec!["harmony:send".to_string()],
237        );
238
239        let json = serde_json::to_string(&token).unwrap();
240        assert!(json.contains("\"machine_token\":\"test_token\""));
241        assert!(json.contains("\"gateway_id\":\"gw123\""));
242        assert!(json.contains("\"gateway_code\":\"gateway-code-123\""));
243
244        // Deserialize and verify
245        let deserialized: MachineToken = serde_json::from_str(&json).unwrap();
246        assert_eq!(deserialized.machine_token, token.machine_token);
247        assert_eq!(deserialized.gateway_id, token.gateway_id);
248    }
249
250    #[tokio::test]
251    async fn test_save_and_load_token() {
252        let temp_dir = TempDir::new().unwrap();
253        let storage = FilesystemStorage::new(temp_dir.path()).unwrap();
254
255        let token = MachineToken::new(
256            "test_token".to_string(),
257            "2025-12-31T23:59:59Z".to_string(),
258            "gw123".to_string(),
259            "gateway-code-123".to_string(),
260            vec!["harmony:send".to_string()],
261        );
262
263        // Save token
264        save_token(&storage, &token).await.unwrap();
265
266        // Load token
267        let loaded = load_token(&storage).await.unwrap();
268        assert!(loaded.is_some());
269
270        let loaded_token = loaded.unwrap();
271        assert_eq!(loaded_token.machine_token, token.machine_token);
272        assert_eq!(loaded_token.gateway_id, token.gateway_id);
273        assert_eq!(loaded_token.gateway_code, token.gateway_code);
274    }
275
276    #[tokio::test]
277    async fn test_load_nonexistent_token() {
278        let temp_dir = TempDir::new().unwrap();
279        let storage = FilesystemStorage::new(temp_dir.path()).unwrap();
280
281        // Load from empty storage
282        let result = load_token(&storage).await.unwrap();
283        assert!(result.is_none());
284    }
285
286    #[tokio::test]
287    async fn test_clear_token() {
288        let temp_dir = TempDir::new().unwrap();
289        let storage = FilesystemStorage::new(temp_dir.path()).unwrap();
290
291        let token = MachineToken::new(
292            "test_token".to_string(),
293            "2025-12-31T23:59:59Z".to_string(),
294            "gw123".to_string(),
295            "gateway-code-123".to_string(),
296            vec![],
297        );
298
299        // Save token
300        save_token(&storage, &token).await.unwrap();
301
302        // Verify it exists
303        assert!(load_token(&storage).await.unwrap().is_some());
304
305        // Clear token
306        clear_token(&storage).await.unwrap();
307
308        // Verify it's gone
309        assert!(load_token(&storage).await.unwrap().is_none());
310    }
311
312    #[tokio::test]
313    async fn test_clear_nonexistent_token() {
314        let temp_dir = TempDir::new().unwrap();
315        let storage = FilesystemStorage::new(temp_dir.path()).unwrap();
316
317        // Clear non-existent token should not error
318        clear_token(&storage).await.unwrap();
319    }
320}