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!(
90        "Saving machine token for gateway: {}",
91        token.gateway_code
92    );
93
94    // Serialize token to JSON
95    let json = serde_json::to_vec_pretty(&token).map_err(|e| {
96        tracing::error!("Failed to serialize machine token: {}", e);
97        StorageError::Config(format!("JSON serialization failed: {}", e))
98    })?;
99
100    // Write to storage
101    storage.write_file_str(TOKEN_STORAGE_PATH, &json).await?;
102
103    tracing::info!(
104        "Machine token saved successfully: gateway_id={}, expires_at={}",
105        token.gateway_id,
106        token.expires_at
107    );
108
109    Ok(())
110}
111
112/// Load a machine token from storage
113///
114/// Attempts to load the token from `runbeam/auth.json`. Returns `None` if the
115/// file doesn't exist.
116///
117/// # Arguments
118///
119/// * `storage` - The storage backend to use
120///
121/// # Returns
122///
123/// Returns `Ok(Some(token))` if a token was found and loaded successfully,
124/// `Ok(None)` if no token file exists, or `Err(StorageError)` if loading failed.
125pub async fn load_token(
126    storage: &dyn StorageBackend,
127) -> Result<Option<MachineToken>, StorageError> {
128    tracing::debug!("Loading machine token from storage");
129
130    // Check if token file exists
131    if !storage.exists_str(TOKEN_STORAGE_PATH) {
132        tracing::debug!("No machine token file found");
133        return Ok(None);
134    }
135
136    // Read token file
137    let json = storage.read_file_str(TOKEN_STORAGE_PATH).await?;
138
139    // Deserialize token
140    let token: MachineToken = serde_json::from_slice(&json).map_err(|e| {
141        tracing::error!("Failed to deserialize machine token: {}", e);
142        StorageError::Config(format!("JSON deserialization failed: {}", e))
143    })?;
144
145    tracing::debug!(
146        "Machine token loaded: gateway_id={}, expires_at={}, valid={}",
147        token.gateway_id,
148        token.expires_at,
149        token.is_valid()
150    );
151
152    Ok(Some(token))
153}
154
155/// Clear the machine token from storage
156///
157/// Removes the token file at `runbeam/auth.json` if it exists.
158///
159/// # Arguments
160///
161/// * `storage` - The storage backend to use
162///
163/// # Returns
164///
165/// Returns `Ok(())` if the token was cleared successfully or didn't exist,
166/// or `Err(StorageError)` if the operation failed.
167pub async fn clear_token(storage: &dyn StorageBackend) -> Result<(), StorageError> {
168    tracing::debug!("Clearing machine token from storage");
169
170    // Check if token file exists
171    if !storage.exists_str(TOKEN_STORAGE_PATH) {
172        tracing::debug!("No machine token file to clear");
173        return Ok(());
174    }
175
176    // Remove the token file
177    storage.remove_str(TOKEN_STORAGE_PATH).await?;
178
179    tracing::info!("Machine token cleared successfully");
180
181    Ok(())
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::storage::FilesystemStorage;
188    use tempfile::TempDir;
189
190    #[test]
191    fn test_machine_token_creation() {
192        let token = MachineToken::new(
193            "test_token".to_string(),
194            "2025-12-31T23:59:59Z".to_string(),
195            "gw123".to_string(),
196            "gateway-code-123".to_string(),
197            vec!["harmony:send".to_string(), "harmony:receive".to_string()],
198        );
199
200        assert_eq!(token.machine_token, "test_token");
201        assert_eq!(token.gateway_id, "gw123");
202        assert_eq!(token.gateway_code, "gateway-code-123");
203        assert_eq!(token.abilities.len(), 2);
204        assert!(!token.issued_at.is_empty());
205    }
206
207    #[test]
208    fn test_machine_token_is_expired() {
209        // Expired token (year 2020)
210        let expired_token = MachineToken::new(
211            "test_token".to_string(),
212            "2020-01-01T00:00:00Z".to_string(),
213            "gw123".to_string(),
214            "gateway-code-123".to_string(),
215            vec![],
216        );
217        assert!(expired_token.is_expired());
218        assert!(!expired_token.is_valid());
219
220        // Valid token (far future)
221        let valid_token = MachineToken::new(
222            "test_token".to_string(),
223            "2099-12-31T23:59:59Z".to_string(),
224            "gw123".to_string(),
225            "gateway-code-123".to_string(),
226            vec![],
227        );
228        assert!(!valid_token.is_expired());
229        assert!(valid_token.is_valid());
230    }
231
232    #[test]
233    fn test_machine_token_serialization() {
234        let token = MachineToken::new(
235            "test_token".to_string(),
236            "2025-12-31T23:59:59Z".to_string(),
237            "gw123".to_string(),
238            "gateway-code-123".to_string(),
239            vec!["harmony:send".to_string()],
240        );
241
242        let json = serde_json::to_string(&token).unwrap();
243        assert!(json.contains("\"machine_token\":\"test_token\""));
244        assert!(json.contains("\"gateway_id\":\"gw123\""));
245        assert!(json.contains("\"gateway_code\":\"gateway-code-123\""));
246
247        // Deserialize and verify
248        let deserialized: MachineToken = serde_json::from_str(&json).unwrap();
249        assert_eq!(deserialized.machine_token, token.machine_token);
250        assert_eq!(deserialized.gateway_id, token.gateway_id);
251    }
252
253    #[tokio::test]
254    async fn test_save_and_load_token() {
255        let temp_dir = TempDir::new().unwrap();
256        let storage = FilesystemStorage::new(temp_dir.path()).unwrap();
257
258        let token = MachineToken::new(
259            "test_token".to_string(),
260            "2025-12-31T23:59:59Z".to_string(),
261            "gw123".to_string(),
262            "gateway-code-123".to_string(),
263            vec!["harmony:send".to_string()],
264        );
265
266        // Save token
267        save_token(&storage, &token).await.unwrap();
268
269        // Load token
270        let loaded = load_token(&storage).await.unwrap();
271        assert!(loaded.is_some());
272
273        let loaded_token = loaded.unwrap();
274        assert_eq!(loaded_token.machine_token, token.machine_token);
275        assert_eq!(loaded_token.gateway_id, token.gateway_id);
276        assert_eq!(loaded_token.gateway_code, token.gateway_code);
277    }
278
279    #[tokio::test]
280    async fn test_load_nonexistent_token() {
281        let temp_dir = TempDir::new().unwrap();
282        let storage = FilesystemStorage::new(temp_dir.path()).unwrap();
283
284        // Load from empty storage
285        let result = load_token(&storage).await.unwrap();
286        assert!(result.is_none());
287    }
288
289    #[tokio::test]
290    async fn test_clear_token() {
291        let temp_dir = TempDir::new().unwrap();
292        let storage = FilesystemStorage::new(temp_dir.path()).unwrap();
293
294        let token = MachineToken::new(
295            "test_token".to_string(),
296            "2025-12-31T23:59:59Z".to_string(),
297            "gw123".to_string(),
298            "gateway-code-123".to_string(),
299            vec![],
300        );
301
302        // Save token
303        save_token(&storage, &token).await.unwrap();
304
305        // Verify it exists
306        assert!(load_token(&storage).await.unwrap().is_some());
307
308        // Clear token
309        clear_token(&storage).await.unwrap();
310
311        // Verify it's gone
312        assert!(load_token(&storage).await.unwrap().is_none());
313    }
314
315    #[tokio::test]
316    async fn test_clear_nonexistent_token() {
317        let temp_dir = TempDir::new().unwrap();
318        let storage = FilesystemStorage::new(temp_dir.path()).unwrap();
319
320        // Clear non-existent token should not error
321        clear_token(&storage).await.unwrap();
322    }
323}