Skip to main content

jax_daemon/
state.rs

1use std::{fs, path::PathBuf};
2
3use common::prelude::SecretKey;
4use serde::{Deserialize, Serialize};
5
6pub const APP_NAME: &str = "jax";
7pub const CONFIG_FILE_NAME: &str = "config.toml";
8pub const DB_FILE_NAME: &str = "db.sqlite";
9pub const KEY_FILE_NAME: &str = "key.pem";
10pub const BLOBS_DIR_NAME: &str = "blobs";
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct AppConfig {
14    /// Port for the API HTTP server (private, mutation/RPC)
15    #[serde(default = "default_api_port")]
16    pub api_port: u16,
17    /// Port for the gateway HTTP server (public, read-only)
18    #[serde(default = "default_gateway_port")]
19    pub gateway_port: u16,
20    /// Listen port for the peer (P2P) node (optional, defaults to ephemeral)
21    #[serde(default)]
22    pub peer_port: Option<u16>,
23    /// Blob storage backend configuration (set at init time)
24    #[serde(default)]
25    pub blob_store: BlobStoreConfig,
26}
27
28fn default_api_port() -> u16 {
29    5001
30}
31
32fn default_gateway_port() -> u16 {
33    8080
34}
35
36impl Default for AppConfig {
37    fn default() -> Self {
38        Self {
39            api_port: default_api_port(),
40            gateway_port: default_gateway_port(),
41            peer_port: None,
42            blob_store: BlobStoreConfig::default(),
43        }
44    }
45}
46
47/// Configuration for the blob storage backend.
48/// This determines where blob data is stored (legacy iroh, local filesystem, or S3).
49#[derive(Debug, Clone, Serialize, Deserialize, Default)]
50#[serde(tag = "type", rename_all = "snake_case")]
51pub enum BlobStoreConfig {
52    /// Legacy iroh FsStore (default, for backwards compatibility)
53    #[default]
54    Legacy,
55
56    /// New SQLite + local filesystem backend
57    Filesystem {
58        /// Absolute path for blob storage
59        path: PathBuf,
60    },
61
62    /// S3-compatible object storage
63    S3 {
64        /// S3 URL in format: s3://access_key:secret_key@endpoint/bucket
65        /// Example: s3://minioadmin:minioadmin@localhost:9000/jax-blobs
66        url: String,
67    },
68}
69
70/// Parsed S3 configuration from URL
71#[derive(Debug, Clone)]
72pub struct S3Config {
73    pub endpoint: String,
74    pub access_key: String,
75    pub secret_key: String,
76    pub bucket: String,
77}
78
79impl BlobStoreConfig {
80    /// Parse S3 URL into components.
81    /// Format: s3://access_key:secret_key@host:port/bucket
82    pub fn parse_s3_url(url: &str) -> Result<S3Config, StateError> {
83        // Remove the s3:// prefix
84        let url = url
85            .strip_prefix("s3://")
86            .ok_or_else(|| StateError::InvalidS3Url("URL must start with s3://".to_string()))?;
87
88        // Split into credentials@host/bucket
89        let (creds, rest) = url
90            .split_once('@')
91            .ok_or_else(|| StateError::InvalidS3Url("Missing @ separator".to_string()))?;
92
93        // Parse credentials (access_key:secret_key)
94        let (access_key, secret_key) = creds
95            .split_once(':')
96            .ok_or_else(|| StateError::InvalidS3Url("Missing : in credentials".to_string()))?;
97
98        // Parse host:port/bucket
99        let (endpoint, bucket) = rest
100            .split_once('/')
101            .ok_or_else(|| StateError::InvalidS3Url("Missing / before bucket".to_string()))?;
102
103        if bucket.is_empty() {
104            return Err(StateError::InvalidS3Url("Bucket name is empty".to_string()));
105        }
106
107        // Determine protocol (default to http for localhost, https otherwise)
108        let protocol = if endpoint.contains("localhost") || endpoint.contains("127.0.0.1") {
109            "http"
110        } else {
111            "https"
112        };
113
114        Ok(S3Config {
115            endpoint: format!("{}://{}", protocol, endpoint),
116            access_key: access_key.to_string(),
117            secret_key: secret_key.to_string(),
118            bucket: bucket.to_string(),
119        })
120    }
121}
122
123#[derive(Debug, Clone)]
124pub struct AppState {
125    /// Path to the jax directory (~/.jax)
126    pub jax_dir: PathBuf,
127    /// Path to the SQLite database
128    pub db_path: PathBuf,
129    /// Path to the node key PEM file
130    pub key_path: PathBuf,
131    /// Path to the blobs directory
132    pub blobs_path: PathBuf,
133    /// Path to the config file
134    pub config_path: PathBuf,
135    /// Loaded configuration
136    pub config: AppConfig,
137}
138
139impl AppState {
140    /// Get the jax directory path (custom or default ~/.jax)
141    pub fn jax_dir(custom_path: Option<PathBuf>) -> Result<PathBuf, StateError> {
142        if let Some(path) = custom_path {
143            return Ok(path);
144        }
145
146        // Use home directory directly since we want ~/.jax
147        let home = dirs::home_dir().ok_or(StateError::NoHomeDirectory)?;
148        Ok(home.join(format!(".{}", APP_NAME)))
149    }
150
151    /// Check if jax directory exists
152    #[allow(dead_code)]
153    pub fn exists(custom_path: Option<PathBuf>) -> Result<bool, StateError> {
154        let jax_dir = Self::jax_dir(custom_path)?;
155        Ok(jax_dir.exists())
156    }
157
158    /// Initialize a new jax state directory
159    pub fn init(
160        custom_path: Option<PathBuf>,
161        config: Option<AppConfig>,
162    ) -> Result<Self, StateError> {
163        let jax_dir = Self::jax_dir(custom_path)?;
164
165        // Create jax directory if it doesn't exist
166        if jax_dir.exists() {
167            return Err(StateError::AlreadyInitialized);
168        }
169
170        fs::create_dir_all(&jax_dir)?;
171
172        // Create subdirectories
173        let blobs_path = jax_dir.join(BLOBS_DIR_NAME);
174        fs::create_dir_all(&blobs_path)?;
175
176        // Generate and save key
177        let key = SecretKey::generate();
178        let key_path = jax_dir.join(KEY_FILE_NAME);
179        fs::write(&key_path, key.to_pem())?;
180
181        // Create config (use provided or default)
182        let config = config.unwrap_or_default();
183        let config_path = jax_dir.join(CONFIG_FILE_NAME);
184        let config_toml = toml::to_string_pretty(&config)?;
185        fs::write(&config_path, config_toml)?;
186
187        // Create empty database (just touch the file, it will be initialized by the service)
188        let db_path = jax_dir.join(DB_FILE_NAME);
189        fs::write(&db_path, "")?;
190
191        Ok(Self {
192            jax_dir,
193            db_path,
194            key_path,
195            blobs_path,
196            config_path,
197            config,
198        })
199    }
200
201    /// Load existing state from jax directory
202    pub fn load(custom_path: Option<PathBuf>) -> Result<Self, StateError> {
203        let jax_dir = Self::jax_dir(custom_path)?;
204
205        if !jax_dir.exists() {
206            return Err(StateError::NotInitialized);
207        }
208
209        // Load paths
210        let db_path = jax_dir.join(DB_FILE_NAME);
211        let key_path = jax_dir.join(KEY_FILE_NAME);
212        let blobs_path = jax_dir.join(BLOBS_DIR_NAME);
213        let config_path = jax_dir.join(CONFIG_FILE_NAME);
214
215        // Verify all required files/directories exist
216        if !db_path.exists() {
217            return Err(StateError::MissingFile("db.sqlite".to_string()));
218        }
219        if !key_path.exists() {
220            return Err(StateError::MissingFile("key.pem".to_string()));
221        }
222        if !blobs_path.exists() {
223            return Err(StateError::MissingFile("blobs/".to_string()));
224        }
225        if !config_path.exists() {
226            return Err(StateError::MissingFile("config.toml".to_string()));
227        }
228
229        // Load config
230        let config_toml = fs::read_to_string(&config_path)?;
231        let config: AppConfig = toml::from_str(&config_toml)?;
232
233        Ok(Self {
234            jax_dir,
235            db_path,
236            key_path,
237            blobs_path,
238            config_path,
239            config,
240        })
241    }
242
243    /// Load the secret key from the key file
244    pub fn load_key(&self) -> Result<SecretKey, StateError> {
245        let pem = fs::read_to_string(&self.key_path)?;
246        let key = SecretKey::from_pem(&pem).map_err(|e| StateError::InvalidKey(e.to_string()))?;
247        Ok(key)
248    }
249}
250
251#[derive(Debug, thiserror::Error)]
252pub enum StateError {
253    #[error("jax directory not initialized. Run 'cli init' first")]
254    NotInitialized,
255
256    #[error("jax directory already initialized")]
257    AlreadyInitialized,
258
259    #[error("no home directory found")]
260    NoHomeDirectory,
261
262    #[error("missing required file: {0}")]
263    MissingFile(String),
264
265    #[error("invalid key: {0}")]
266    InvalidKey(String),
267
268    #[error("invalid S3 URL: {0}")]
269    InvalidS3Url(String),
270
271    #[error("IO error: {0}")]
272    Io(#[from] std::io::Error),
273
274    #[error("TOML serialization error: {0}")]
275    TomlSer(#[from] toml::ser::Error),
276
277    #[error("TOML deserialization error: {0}")]
278    TomlDe(#[from] toml::de::Error),
279}