Skip to main content

hashtree_cli/
config.rs

1use anyhow::{Context, Result};
2use nostr::nips::nip19::{FromBech32, ToBech32};
3use nostr::{Keys, SecretKey};
4use serde::{Deserialize, Serialize};
5use std::fs;
6
7#[derive(Debug, Clone, Default, Serialize, Deserialize)]
8pub struct Config {
9    #[serde(default)]
10    pub server: ServerConfig,
11    #[serde(default)]
12    pub storage: StorageConfig,
13    #[serde(default)]
14    pub nostr: NostrConfig,
15    #[serde(default)]
16    pub blossom: BlossomConfig,
17    #[serde(default)]
18    pub sync: SyncConfig,
19    #[serde(default)]
20    pub cashu: CashuConfig,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ServerConfig {
25    #[serde(default = "default_bind_address")]
26    pub bind_address: String,
27    #[serde(default = "default_enable_auth")]
28    pub enable_auth: bool,
29    /// Port for the built-in STUN server (0 = disabled)
30    #[serde(default = "default_stun_port")]
31    pub stun_port: u16,
32    /// Enable WebRTC P2P connections
33    #[serde(default = "default_enable_webrtc")]
34    pub enable_webrtc: bool,
35    /// Enable LAN multicast discovery/signaling for native peers.
36    #[serde(default = "default_enable_multicast")]
37    pub enable_multicast: bool,
38    /// IPv4 multicast group used for LAN discovery/signaling.
39    #[serde(default = "default_multicast_group")]
40    pub multicast_group: String,
41    /// UDP port used for LAN multicast discovery/signaling.
42    #[serde(default = "default_multicast_port")]
43    pub multicast_port: u16,
44    /// Maximum peers admitted from LAN multicast discovery.
45    /// Set to 0 to disable multicast even when enable_multicast is true.
46    #[serde(default = "default_max_multicast_peers")]
47    pub max_multicast_peers: usize,
48    /// Enable Android Wi-Fi Aware nearby discovery/signaling for native peers.
49    #[serde(default = "default_enable_wifi_aware")]
50    pub enable_wifi_aware: bool,
51    /// Maximum peers admitted from Wi-Fi Aware discovery.
52    /// Set to 0 to disable Wi-Fi Aware even when enable_wifi_aware is true.
53    #[serde(default = "default_max_wifi_aware_peers")]
54    pub max_wifi_aware_peers: usize,
55    /// Enable native Bluetooth discovery/transport for nearby peers.
56    #[serde(default = "default_enable_bluetooth")]
57    pub enable_bluetooth: bool,
58    /// Maximum peers admitted from Bluetooth discovery.
59    /// Set to 0 to disable Bluetooth even when enable_bluetooth is true.
60    #[serde(default = "default_max_bluetooth_peers")]
61    pub max_bluetooth_peers: usize,
62    /// Allow anyone with valid Nostr auth to write (default: true)
63    /// When false, only social graph members can write
64    #[serde(default = "default_public_writes")]
65    pub public_writes: bool,
66    /// Allow public access to social graph snapshot endpoint (default: false)
67    #[serde(default = "default_socialgraph_snapshot_public")]
68    pub socialgraph_snapshot_public: bool,
69}
70
71fn default_public_writes() -> bool {
72    true
73}
74
75fn default_socialgraph_snapshot_public() -> bool {
76    false
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct StorageConfig {
81    #[serde(default = "default_data_dir")]
82    pub data_dir: String,
83    #[serde(default = "default_max_size_gb")]
84    pub max_size_gb: u64,
85    /// Optional S3/R2 backend for blob storage
86    #[serde(default)]
87    pub s3: Option<S3Config>,
88}
89
90/// S3-compatible storage configuration (works with AWS S3, Cloudflare R2, MinIO, etc.)
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct S3Config {
93    /// S3 endpoint URL (e.g., "https://<account_id>.r2.cloudflarestorage.com" for R2)
94    pub endpoint: String,
95    /// S3 bucket name
96    pub bucket: String,
97    /// Optional key prefix for all blobs (e.g., "blobs/")
98    #[serde(default)]
99    pub prefix: Option<String>,
100    /// AWS region (use "auto" for R2)
101    #[serde(default = "default_s3_region")]
102    pub region: String,
103    /// Access key ID (can also be set via AWS_ACCESS_KEY_ID env var)
104    #[serde(default)]
105    pub access_key: Option<String>,
106    /// Secret access key (can also be set via AWS_SECRET_ACCESS_KEY env var)
107    #[serde(default)]
108    pub secret_key: Option<String>,
109    /// Public URL for serving blobs (optional, for generating public URLs)
110    #[serde(default)]
111    pub public_url: Option<String>,
112}
113
114fn default_s3_region() -> String {
115    "auto".to_string()
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct NostrConfig {
120    #[serde(default = "default_nostr_enabled")]
121    pub enabled: bool,
122    #[serde(default = "default_relays")]
123    pub relays: Vec<String>,
124    /// List of npubs allowed to write (blossom uploads). If empty, uses public_writes setting.
125    #[serde(default)]
126    pub allowed_npubs: Vec<String>,
127    /// Social graph root pubkey (npub). Defaults to own key if not set.
128    #[serde(default)]
129    pub socialgraph_root: Option<String>,
130    /// Pubkeys to seed into contacts.json when a new identity is initialized.
131    /// Set to [] to opt out.
132    #[serde(default = "default_nostr_bootstrap_follows")]
133    pub bootstrap_follows: Vec<String>,
134    /// How many hops to crawl the social graph (default: 2)
135    #[serde(default = "default_social_graph_crawl_depth", alias = "crawl_depth")]
136    pub social_graph_crawl_depth: u32,
137    /// Max follow distance for write access (default: 3)
138    #[serde(default = "default_max_write_distance")]
139    pub max_write_distance: u32,
140    /// Max size for the trusted social graph store in GB (default: 10)
141    #[serde(default = "default_nostr_db_max_size_gb")]
142    pub db_max_size_gb: u64,
143    /// Max size for the social graph spambox in GB (default: 1)
144    /// Set to 0 for memory-only spambox (no on-disk DB)
145    #[serde(default = "default_nostr_spambox_max_size_gb")]
146    pub spambox_max_size_gb: u64,
147    /// Require relays to support NIP-77 negentropy for mirror history sync.
148    #[serde(default)]
149    pub negentropy_only: bool,
150    /// Threshold for treating a user as overmuted in mirrored profile indexing/search.
151    #[serde(default = "default_nostr_overmute_threshold")]
152    pub overmute_threshold: f64,
153    /// Kinds mirrored from upstream relays for the trusted hashtree index.
154    #[serde(default = "default_nostr_mirror_kinds")]
155    pub mirror_kinds: Vec<u16>,
156    /// How many graph authors to reconcile before checkpointing the mirror root.
157    #[serde(default = "default_nostr_history_sync_author_chunk_size")]
158    pub history_sync_author_chunk_size: usize,
159    /// Run a catch-up history sync after relay reconnects.
160    #[serde(default = "default_nostr_history_sync_on_reconnect")]
161    pub history_sync_on_reconnect: bool,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct BlossomConfig {
166    #[serde(default = "default_blossom_enabled")]
167    pub enabled: bool,
168    /// File servers for push/pull (legacy, both read and write)
169    #[serde(default)]
170    pub servers: Vec<String>,
171    /// Read-only file servers (fallback for fetching content)
172    #[serde(default = "default_read_servers")]
173    pub read_servers: Vec<String>,
174    /// Write-enabled file servers (for uploading)
175    #[serde(default = "default_write_servers")]
176    pub write_servers: Vec<String>,
177    /// Maximum upload size in MB (default: 5)
178    #[serde(default = "default_max_upload_mb")]
179    pub max_upload_mb: u64,
180}
181
182impl BlossomConfig {
183    pub fn all_read_servers(&self) -> Vec<String> {
184        if !self.enabled {
185            return Vec::new();
186        }
187        let mut servers = self.servers.clone();
188        servers.extend(self.read_servers.clone());
189        servers.extend(self.write_servers.clone());
190        if servers.is_empty() {
191            servers = default_read_servers();
192            servers.extend(default_write_servers());
193        }
194        servers.sort();
195        servers.dedup();
196        servers
197    }
198
199    pub fn all_write_servers(&self) -> Vec<String> {
200        if !self.enabled {
201            return Vec::new();
202        }
203        let mut servers = self.servers.clone();
204        servers.extend(self.write_servers.clone());
205        if servers.is_empty() {
206            servers = default_write_servers();
207        }
208        servers.sort();
209        servers.dedup();
210        servers
211    }
212}
213
214impl NostrConfig {
215    pub fn active_relays(&self) -> Vec<String> {
216        if self.enabled {
217            self.relays.clone()
218        } else {
219            Vec::new()
220        }
221    }
222}
223
224// Keep in sync with hashtree-config/src/lib.rs
225fn default_read_servers() -> Vec<String> {
226    let mut servers = vec![
227        "https://blossom.primal.net".to_string(),
228        "https://cdn.iris.to".to_string(),
229        "https://hashtree.iris.to".to_string(),
230    ];
231    servers.sort();
232    servers
233}
234
235fn default_write_servers() -> Vec<String> {
236    vec!["https://upload.iris.to".to_string()]
237}
238
239fn default_max_upload_mb() -> u64 {
240    5
241}
242
243fn default_nostr_enabled() -> bool {
244    true
245}
246
247fn default_blossom_enabled() -> bool {
248    true
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct SyncConfig {
253    /// Enable background sync (auto-pull trees)
254    #[serde(default = "default_sync_enabled")]
255    pub enabled: bool,
256    /// Sync own trees (subscribed via Nostr)
257    #[serde(default = "default_sync_own")]
258    pub sync_own: bool,
259    /// Sync followed users' public trees
260    #[serde(default = "default_sync_followed")]
261    pub sync_followed: bool,
262    /// Max concurrent sync tasks
263    #[serde(default = "default_max_concurrent")]
264    pub max_concurrent: usize,
265    /// WebRTC request timeout in milliseconds
266    #[serde(default = "default_webrtc_timeout_ms")]
267    pub webrtc_timeout_ms: u64,
268    /// Blossom request timeout in milliseconds
269    #[serde(default = "default_blossom_timeout_ms")]
270    pub blossom_timeout_ms: u64,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct CashuConfig {
275    /// Cashu mint base URLs we accept for bandwidth incentives.
276    #[serde(default)]
277    pub accepted_mints: Vec<String>,
278    /// Default mint to use for wallet operations.
279    #[serde(default)]
280    pub default_mint: Option<String>,
281    /// Default post-delivery payment offer for quoted retrievals.
282    #[serde(default = "default_cashu_quote_payment_offer_sat")]
283    pub quote_payment_offer_sat: u64,
284    /// Quote validity window in milliseconds.
285    #[serde(default = "default_cashu_quote_ttl_ms")]
286    pub quote_ttl_ms: u32,
287    /// Maximum time to wait for post-delivery settlement before recording a default.
288    #[serde(default = "default_cashu_settlement_timeout_ms")]
289    pub settlement_timeout_ms: u64,
290    /// Block mints whose failed redemptions keep outnumbering successful redemptions.
291    #[serde(default = "default_cashu_mint_failure_block_threshold")]
292    pub mint_failure_block_threshold: u64,
293    /// Base cap for trying a peer-suggested mint we do not already trust.
294    #[serde(default = "default_cashu_peer_suggested_mint_base_cap_sat")]
295    pub peer_suggested_mint_base_cap_sat: u64,
296    /// Additional cap granted per successful delivery from that peer.
297    #[serde(default = "default_cashu_peer_suggested_mint_success_step_sat")]
298    pub peer_suggested_mint_success_step_sat: u64,
299    /// Additional cap granted per settled payment received from that peer.
300    #[serde(default = "default_cashu_peer_suggested_mint_receipt_step_sat")]
301    pub peer_suggested_mint_receipt_step_sat: u64,
302    /// Hard ceiling for untrusted peer-suggested mint exposure.
303    #[serde(default = "default_cashu_peer_suggested_mint_max_cap_sat")]
304    pub peer_suggested_mint_max_cap_sat: u64,
305    /// Block serving peers whose unpaid defaults reach this threshold.
306    #[serde(default)]
307    pub payment_default_block_threshold: u64,
308    /// Target chunk size for quoted paid delivery.
309    #[serde(default = "default_cashu_chunk_target_bytes")]
310    pub chunk_target_bytes: usize,
311}
312
313impl Default for CashuConfig {
314    fn default() -> Self {
315        Self {
316            accepted_mints: Vec::new(),
317            default_mint: None,
318            quote_payment_offer_sat: default_cashu_quote_payment_offer_sat(),
319            quote_ttl_ms: default_cashu_quote_ttl_ms(),
320            settlement_timeout_ms: default_cashu_settlement_timeout_ms(),
321            mint_failure_block_threshold: default_cashu_mint_failure_block_threshold(),
322            peer_suggested_mint_base_cap_sat: default_cashu_peer_suggested_mint_base_cap_sat(),
323            peer_suggested_mint_success_step_sat:
324                default_cashu_peer_suggested_mint_success_step_sat(),
325            peer_suggested_mint_receipt_step_sat:
326                default_cashu_peer_suggested_mint_receipt_step_sat(),
327            peer_suggested_mint_max_cap_sat: default_cashu_peer_suggested_mint_max_cap_sat(),
328            payment_default_block_threshold: 0,
329            chunk_target_bytes: default_cashu_chunk_target_bytes(),
330        }
331    }
332}
333
334fn default_cashu_quote_payment_offer_sat() -> u64 {
335    3
336}
337
338fn default_cashu_quote_ttl_ms() -> u32 {
339    1_500
340}
341
342fn default_cashu_settlement_timeout_ms() -> u64 {
343    5_000
344}
345
346fn default_cashu_mint_failure_block_threshold() -> u64 {
347    2
348}
349
350fn default_cashu_peer_suggested_mint_base_cap_sat() -> u64 {
351    3
352}
353
354fn default_cashu_peer_suggested_mint_success_step_sat() -> u64 {
355    1
356}
357
358fn default_cashu_peer_suggested_mint_receipt_step_sat() -> u64 {
359    2
360}
361
362fn default_cashu_peer_suggested_mint_max_cap_sat() -> u64 {
363    21
364}
365
366fn default_cashu_chunk_target_bytes() -> usize {
367    32 * 1024
368}
369
370fn default_sync_enabled() -> bool {
371    true
372}
373
374fn default_sync_own() -> bool {
375    true
376}
377
378fn default_sync_followed() -> bool {
379    true
380}
381
382fn default_max_concurrent() -> usize {
383    3
384}
385
386fn default_webrtc_timeout_ms() -> u64 {
387    2000
388}
389
390fn default_blossom_timeout_ms() -> u64 {
391    10000
392}
393
394fn default_social_graph_crawl_depth() -> u32 {
395    2
396}
397
398fn default_nostr_bootstrap_follows() -> Vec<String> {
399    vec![hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB.to_string()]
400}
401
402fn default_max_write_distance() -> u32 {
403    3
404}
405
406fn default_nostr_db_max_size_gb() -> u64 {
407    10
408}
409
410fn default_nostr_spambox_max_size_gb() -> u64 {
411    1
412}
413
414fn default_nostr_history_sync_on_reconnect() -> bool {
415    true
416}
417
418fn default_nostr_overmute_threshold() -> f64 {
419    1.0
420}
421
422fn default_nostr_mirror_kinds() -> Vec<u16> {
423    vec![0, 3]
424}
425
426fn default_nostr_history_sync_author_chunk_size() -> usize {
427    5_000
428}
429
430fn default_relays() -> Vec<String> {
431    vec![
432        "wss://relay.damus.io".to_string(),
433        "wss://relay.snort.social".to_string(),
434        "wss://temp.iris.to".to_string(),
435        "wss://upload.iris.to/nostr".to_string(),
436    ]
437}
438
439fn default_bind_address() -> String {
440    "127.0.0.1:8080".to_string()
441}
442
443fn default_enable_auth() -> bool {
444    true
445}
446
447fn default_stun_port() -> u16 {
448    3478 // Standard STUN port (RFC 5389)
449}
450
451fn default_enable_webrtc() -> bool {
452    true
453}
454
455fn default_enable_multicast() -> bool {
456    true
457}
458
459fn default_multicast_group() -> String {
460    "239.255.42.98".to_string()
461}
462
463fn default_multicast_port() -> u16 {
464    48555
465}
466
467fn default_max_multicast_peers() -> usize {
468    12
469}
470
471fn default_enable_wifi_aware() -> bool {
472    false
473}
474
475fn default_max_wifi_aware_peers() -> usize {
476    0
477}
478
479fn default_enable_bluetooth() -> bool {
480    false
481}
482
483fn default_max_bluetooth_peers() -> usize {
484    0
485}
486
487fn default_data_dir() -> String {
488    hashtree_config::get_hashtree_dir()
489        .join("data")
490        .to_string_lossy()
491        .to_string()
492}
493
494fn default_max_size_gb() -> u64 {
495    10
496}
497
498impl Default for ServerConfig {
499    fn default() -> Self {
500        Self {
501            bind_address: default_bind_address(),
502            enable_auth: default_enable_auth(),
503            stun_port: default_stun_port(),
504            enable_webrtc: default_enable_webrtc(),
505            enable_multicast: default_enable_multicast(),
506            multicast_group: default_multicast_group(),
507            multicast_port: default_multicast_port(),
508            max_multicast_peers: default_max_multicast_peers(),
509            enable_wifi_aware: default_enable_wifi_aware(),
510            max_wifi_aware_peers: default_max_wifi_aware_peers(),
511            enable_bluetooth: default_enable_bluetooth(),
512            max_bluetooth_peers: default_max_bluetooth_peers(),
513            public_writes: default_public_writes(),
514            socialgraph_snapshot_public: default_socialgraph_snapshot_public(),
515        }
516    }
517}
518
519impl Default for StorageConfig {
520    fn default() -> Self {
521        Self {
522            data_dir: default_data_dir(),
523            max_size_gb: default_max_size_gb(),
524            s3: None,
525        }
526    }
527}
528
529impl Default for NostrConfig {
530    fn default() -> Self {
531        Self {
532            enabled: default_nostr_enabled(),
533            relays: default_relays(),
534            allowed_npubs: Vec::new(),
535            socialgraph_root: None,
536            bootstrap_follows: default_nostr_bootstrap_follows(),
537            social_graph_crawl_depth: default_social_graph_crawl_depth(),
538            max_write_distance: default_max_write_distance(),
539            db_max_size_gb: default_nostr_db_max_size_gb(),
540            spambox_max_size_gb: default_nostr_spambox_max_size_gb(),
541            negentropy_only: false,
542            overmute_threshold: default_nostr_overmute_threshold(),
543            mirror_kinds: default_nostr_mirror_kinds(),
544            history_sync_author_chunk_size: default_nostr_history_sync_author_chunk_size(),
545            history_sync_on_reconnect: default_nostr_history_sync_on_reconnect(),
546        }
547    }
548}
549
550impl Default for BlossomConfig {
551    fn default() -> Self {
552        Self {
553            enabled: default_blossom_enabled(),
554            servers: Vec::new(),
555            read_servers: default_read_servers(),
556            write_servers: default_write_servers(),
557            max_upload_mb: default_max_upload_mb(),
558        }
559    }
560}
561
562impl Default for SyncConfig {
563    fn default() -> Self {
564        Self {
565            enabled: default_sync_enabled(),
566            sync_own: default_sync_own(),
567            sync_followed: default_sync_followed(),
568            max_concurrent: default_max_concurrent(),
569            webrtc_timeout_ms: default_webrtc_timeout_ms(),
570            blossom_timeout_ms: default_blossom_timeout_ms(),
571        }
572    }
573}
574
575impl Config {
576    /// Load config from file, or create default if doesn't exist
577    pub fn load() -> Result<Self> {
578        let config_path = get_config_path();
579
580        if config_path.exists() {
581            let content = fs::read_to_string(&config_path).context("Failed to read config file")?;
582            toml::from_str(&content).context("Failed to parse config file")
583        } else {
584            let config = Config::default();
585            config.save()?;
586            Ok(config)
587        }
588    }
589
590    /// Save config to file
591    pub fn save(&self) -> Result<()> {
592        let config_path = get_config_path();
593
594        // Ensure parent directory exists
595        if let Some(parent) = config_path.parent() {
596            fs::create_dir_all(parent)?;
597        }
598
599        let content = toml::to_string_pretty(self)?;
600        fs::write(&config_path, content)?;
601
602        Ok(())
603    }
604}
605
606// Re-export path functions from hashtree_config
607pub use hashtree_config::{get_auth_cookie_path, get_config_path, get_hashtree_dir, get_keys_path};
608
609/// Generate and save auth cookie if it doesn't exist
610pub fn ensure_auth_cookie() -> Result<(String, String)> {
611    let cookie_path = get_auth_cookie_path();
612
613    if cookie_path.exists() {
614        read_auth_cookie()
615    } else {
616        generate_auth_cookie()
617    }
618}
619
620/// Read existing auth cookie
621pub fn read_auth_cookie() -> Result<(String, String)> {
622    let cookie_path = get_auth_cookie_path();
623    let content = fs::read_to_string(&cookie_path).context("Failed to read auth cookie")?;
624
625    let parts: Vec<&str> = content.trim().split(':').collect();
626    if parts.len() != 2 {
627        anyhow::bail!("Invalid auth cookie format");
628    }
629
630    Ok((parts[0].to_string(), parts[1].to_string()))
631}
632
633/// Ensure keys file exists, generating one if not present
634/// Returns (Keys, was_generated)
635pub fn ensure_keys() -> Result<(Keys, bool)> {
636    let keys_path = get_keys_path();
637
638    if keys_path.exists() {
639        let content = fs::read_to_string(&keys_path).context("Failed to read keys file")?;
640        let entries = hashtree_config::parse_keys_file(&content);
641        let nsec_str = entries
642            .into_iter()
643            .next()
644            .map(|e| e.secret)
645            .context("Keys file is empty")?;
646        let secret_key = SecretKey::from_bech32(&nsec_str).context("Invalid nsec format")?;
647        let keys = Keys::new(secret_key);
648        Ok((keys, false))
649    } else {
650        let keys = generate_keys()?;
651        Ok((keys, true))
652    }
653}
654
655/// Read existing keys
656pub fn read_keys() -> Result<Keys> {
657    let keys_path = get_keys_path();
658    let content = fs::read_to_string(&keys_path).context("Failed to read keys file")?;
659    let entries = hashtree_config::parse_keys_file(&content);
660    let nsec_str = entries
661        .into_iter()
662        .next()
663        .map(|e| e.secret)
664        .context("Keys file is empty")?;
665    let secret_key = SecretKey::from_bech32(&nsec_str).context("Invalid nsec format")?;
666    Ok(Keys::new(secret_key))
667}
668
669/// Get nsec string, ensuring keys file exists (generate if needed)
670/// Returns (nsec_string, was_generated)
671pub fn ensure_keys_string() -> Result<(String, bool)> {
672    let keys_path = get_keys_path();
673
674    if keys_path.exists() {
675        let content = fs::read_to_string(&keys_path).context("Failed to read keys file")?;
676        let entries = hashtree_config::parse_keys_file(&content);
677        let nsec_str = entries
678            .into_iter()
679            .next()
680            .map(|e| e.secret)
681            .context("Keys file is empty")?;
682        Ok((nsec_str, false))
683    } else {
684        let keys = generate_keys()?;
685        let nsec = keys
686            .secret_key()
687            .to_bech32()
688            .context("Failed to encode nsec")?;
689        Ok((nsec, true))
690    }
691}
692
693/// Generate new keys and save to file
694pub fn generate_keys() -> Result<Keys> {
695    let keys_path = get_keys_path();
696
697    // Ensure parent directory exists
698    if let Some(parent) = keys_path.parent() {
699        fs::create_dir_all(parent)?;
700    }
701
702    // Generate new keys
703    let keys = Keys::generate();
704    let nsec = keys
705        .secret_key()
706        .to_bech32()
707        .context("Failed to encode nsec")?;
708
709    // Save to file
710    fs::write(&keys_path, &nsec)?;
711
712    // Set permissions to 0600 (owner read/write only)
713    #[cfg(unix)]
714    {
715        use std::os::unix::fs::PermissionsExt;
716        let perms = fs::Permissions::from_mode(0o600);
717        fs::set_permissions(&keys_path, perms)?;
718    }
719
720    if let Ok(config) = Config::load() {
721        let data_dir = std::path::PathBuf::from(&config.storage.data_dir);
722        let _ = crate::bootstrap::seed_identity_defaults(&data_dir, &config);
723    }
724
725    Ok(keys)
726}
727
728/// Get 32-byte pubkey bytes from Keys.
729pub fn pubkey_bytes(keys: &Keys) -> [u8; 32] {
730    keys.public_key().to_bytes()
731}
732
733/// Parse npub to 32-byte pubkey
734pub fn parse_npub(npub: &str) -> Result<[u8; 32]> {
735    use nostr::PublicKey;
736    let pk = PublicKey::from_bech32(npub).context("Invalid npub format")?;
737    Ok(pk.to_bytes())
738}
739
740/// Generate new random auth cookie
741pub fn generate_auth_cookie() -> Result<(String, String)> {
742    use rand::Rng;
743
744    let cookie_path = get_auth_cookie_path();
745
746    // Ensure parent directory exists
747    if let Some(parent) = cookie_path.parent() {
748        fs::create_dir_all(parent)?;
749    }
750
751    // Generate random credentials
752    let mut rng = rand::thread_rng();
753    let username = format!("htree_{}", rng.gen::<u32>());
754    let password: String = (0..32)
755        .map(|_| {
756            let idx = rng.gen_range(0..62);
757            match idx {
758                0..=25 => (b'a' + idx) as char,
759                26..=51 => (b'A' + (idx - 26)) as char,
760                _ => (b'0' + (idx - 52)) as char,
761            }
762        })
763        .collect();
764
765    // Save to file
766    let content = format!("{}:{}", username, password);
767    fs::write(&cookie_path, content)?;
768
769    // Set permissions to 0600 (owner read/write only)
770    #[cfg(unix)]
771    {
772        use std::os::unix::fs::PermissionsExt;
773        let perms = fs::Permissions::from_mode(0o600);
774        fs::set_permissions(&cookie_path, perms)?;
775    }
776
777    Ok((username, password))
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783    use tempfile::TempDir;
784
785    #[test]
786    fn test_config_default() {
787        let config = Config::default();
788        assert_eq!(config.server.bind_address, "127.0.0.1:8080");
789        assert!(config.server.enable_auth);
790        assert!(config.server.enable_multicast);
791        assert_eq!(config.server.multicast_group, "239.255.42.98");
792        assert_eq!(config.server.multicast_port, 48555);
793        assert_eq!(config.server.max_multicast_peers, 12);
794        assert!(!config.server.enable_wifi_aware);
795        assert_eq!(config.server.max_wifi_aware_peers, 0);
796        assert!(!config.server.enable_bluetooth);
797        assert_eq!(config.server.max_bluetooth_peers, 0);
798        assert_eq!(config.storage.max_size_gb, 10);
799        assert!(config.nostr.enabled);
800        assert!(config
801            .nostr
802            .relays
803            .contains(&"wss://upload.iris.to/nostr".to_string()));
804        assert!(config.blossom.enabled);
805        assert_eq!(config.nostr.social_graph_crawl_depth, 2);
806        assert_eq!(config.nostr.max_write_distance, 3);
807        assert_eq!(config.nostr.db_max_size_gb, 10);
808        assert_eq!(config.nostr.spambox_max_size_gb, 1);
809        assert!(!config.nostr.negentropy_only);
810        assert_eq!(config.nostr.overmute_threshold, 1.0);
811        assert_eq!(config.nostr.mirror_kinds, vec![0, 3]);
812        assert_eq!(config.nostr.history_sync_author_chunk_size, 5_000);
813        assert!(config.nostr.history_sync_on_reconnect);
814        assert!(config.nostr.socialgraph_root.is_none());
815        assert_eq!(
816            config.nostr.bootstrap_follows,
817            vec![hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB.to_string()]
818        );
819        assert!(!config.server.socialgraph_snapshot_public);
820        assert!(config.cashu.accepted_mints.is_empty());
821        assert!(config.cashu.default_mint.is_none());
822        assert_eq!(config.cashu.quote_payment_offer_sat, 3);
823        assert_eq!(config.cashu.quote_ttl_ms, 1_500);
824        assert_eq!(config.cashu.settlement_timeout_ms, 5_000);
825        assert_eq!(config.cashu.mint_failure_block_threshold, 2);
826        assert_eq!(config.cashu.peer_suggested_mint_base_cap_sat, 3);
827        assert_eq!(config.cashu.peer_suggested_mint_success_step_sat, 1);
828        assert_eq!(config.cashu.peer_suggested_mint_receipt_step_sat, 2);
829        assert_eq!(config.cashu.peer_suggested_mint_max_cap_sat, 21);
830        assert_eq!(config.cashu.payment_default_block_threshold, 0);
831        assert_eq!(config.cashu.chunk_target_bytes, 32 * 1024);
832    }
833
834    #[test]
835    fn test_nostr_config_deserialize_with_defaults() {
836        let toml_str = r#"
837[nostr]
838relays = ["wss://relay.damus.io"]
839"#;
840        let config: Config = toml::from_str(toml_str).unwrap();
841        assert!(config.nostr.enabled);
842        assert_eq!(config.nostr.relays, vec!["wss://relay.damus.io"]);
843        assert_eq!(config.nostr.social_graph_crawl_depth, 2);
844        assert_eq!(config.nostr.max_write_distance, 3);
845        assert_eq!(config.nostr.db_max_size_gb, 10);
846        assert_eq!(config.nostr.spambox_max_size_gb, 1);
847        assert!(!config.nostr.negentropy_only);
848        assert_eq!(config.nostr.overmute_threshold, 1.0);
849        assert_eq!(config.nostr.mirror_kinds, vec![0, 3]);
850        assert_eq!(config.nostr.history_sync_author_chunk_size, 5_000);
851        assert!(config.nostr.history_sync_on_reconnect);
852        assert!(config.nostr.socialgraph_root.is_none());
853        assert_eq!(
854            config.nostr.bootstrap_follows,
855            vec![hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB.to_string()]
856        );
857    }
858
859    #[test]
860    fn test_nostr_config_deserialize_with_socialgraph() {
861        let toml_str = r#"
862[nostr]
863relays = ["wss://relay.damus.io"]
864socialgraph_root = "npub1test"
865bootstrap_follows = []
866social_graph_crawl_depth = 3
867max_write_distance = 5
868negentropy_only = true
869overmute_threshold = 2.5
870mirror_kinds = [0, 10000]
871history_sync_author_chunk_size = 250
872history_sync_on_reconnect = false
873"#;
874        let config: Config = toml::from_str(toml_str).unwrap();
875        assert!(config.nostr.enabled);
876        assert_eq!(config.nostr.socialgraph_root, Some("npub1test".to_string()));
877        assert!(config.nostr.bootstrap_follows.is_empty());
878        assert_eq!(config.nostr.social_graph_crawl_depth, 3);
879        assert_eq!(config.nostr.max_write_distance, 5);
880        assert_eq!(config.nostr.db_max_size_gb, 10);
881        assert_eq!(config.nostr.spambox_max_size_gb, 1);
882        assert!(config.nostr.negentropy_only);
883        assert_eq!(config.nostr.overmute_threshold, 2.5);
884        assert_eq!(config.nostr.mirror_kinds, vec![0, 10_000]);
885        assert_eq!(config.nostr.history_sync_author_chunk_size, 250);
886        assert!(!config.nostr.history_sync_on_reconnect);
887    }
888
889    #[test]
890    fn test_nostr_config_deserialize_legacy_crawl_depth_alias() {
891        let toml_str = r#"
892[nostr]
893relays = ["wss://relay.damus.io"]
894crawl_depth = 4
895"#;
896        let config: Config = toml::from_str(toml_str).unwrap();
897        assert_eq!(config.nostr.social_graph_crawl_depth, 4);
898    }
899
900    #[test]
901    fn test_server_config_deserialize_with_multicast() {
902        let toml_str = r#"
903[server]
904enable_multicast = true
905multicast_group = "239.255.42.99"
906multicast_port = 49001
907max_multicast_peers = 12
908enable_wifi_aware = true
909max_wifi_aware_peers = 5
910enable_bluetooth = true
911max_bluetooth_peers = 6
912"#;
913        let config: Config = toml::from_str(toml_str).unwrap();
914        assert!(config.server.enable_multicast);
915        assert_eq!(config.server.multicast_group, "239.255.42.99");
916        assert_eq!(config.server.multicast_port, 49_001);
917        assert_eq!(config.server.max_multicast_peers, 12);
918        assert!(config.server.enable_wifi_aware);
919        assert_eq!(config.server.max_wifi_aware_peers, 5);
920        assert!(config.server.enable_bluetooth);
921        assert_eq!(config.server.max_bluetooth_peers, 6);
922    }
923
924    #[test]
925    fn test_cashu_config_deserialize_with_accepted_mints() {
926        let toml_str = r#"
927[cashu]
928accepted_mints = ["https://mint1.example", "http://127.0.0.1:3338"]
929default_mint = "https://mint1.example"
930quote_payment_offer_sat = 5
931quote_ttl_ms = 2500
932settlement_timeout_ms = 7000
933mint_failure_block_threshold = 3
934peer_suggested_mint_base_cap_sat = 4
935peer_suggested_mint_success_step_sat = 2
936peer_suggested_mint_receipt_step_sat = 3
937peer_suggested_mint_max_cap_sat = 34
938payment_default_block_threshold = 2
939chunk_target_bytes = 65536
940"#;
941        let config: Config = toml::from_str(toml_str).unwrap();
942        assert_eq!(
943            config.cashu.accepted_mints,
944            vec![
945                "https://mint1.example".to_string(),
946                "http://127.0.0.1:3338".to_string()
947            ]
948        );
949        assert_eq!(
950            config.cashu.default_mint,
951            Some("https://mint1.example".to_string())
952        );
953        assert_eq!(config.cashu.quote_payment_offer_sat, 5);
954        assert_eq!(config.cashu.quote_ttl_ms, 2500);
955        assert_eq!(config.cashu.settlement_timeout_ms, 7_000);
956        assert_eq!(config.cashu.mint_failure_block_threshold, 3);
957        assert_eq!(config.cashu.peer_suggested_mint_base_cap_sat, 4);
958        assert_eq!(config.cashu.peer_suggested_mint_success_step_sat, 2);
959        assert_eq!(config.cashu.peer_suggested_mint_receipt_step_sat, 3);
960        assert_eq!(config.cashu.peer_suggested_mint_max_cap_sat, 34);
961        assert_eq!(config.cashu.payment_default_block_threshold, 2);
962        assert_eq!(config.cashu.chunk_target_bytes, 65_536);
963    }
964
965    #[test]
966    fn test_auth_cookie_generation() -> Result<()> {
967        let temp_dir = TempDir::new()?;
968
969        // Mock the cookie path
970        std::env::set_var("HOME", temp_dir.path());
971
972        let (username, password) = generate_auth_cookie()?;
973
974        assert!(username.starts_with("htree_"));
975        assert_eq!(password.len(), 32);
976
977        // Verify cookie file exists
978        let cookie_path = get_auth_cookie_path();
979        assert!(cookie_path.exists());
980
981        // Verify reading works
982        let (u2, p2) = read_auth_cookie()?;
983        assert_eq!(username, u2);
984        assert_eq!(password, p2);
985
986        Ok(())
987    }
988
989    #[test]
990    fn test_blossom_read_servers_include_write_only_servers_as_fresh_fallbacks() {
991        let mut config = BlossomConfig::default();
992        config.servers = vec!["https://legacy.server".to_string()];
993
994        let read = config.all_read_servers();
995        assert!(read.contains(&"https://legacy.server".to_string()));
996        assert!(read.contains(&"https://cdn.iris.to".to_string()));
997        assert!(read.contains(&"https://blossom.primal.net".to_string()));
998        assert!(read.contains(&"https://upload.iris.to".to_string()));
999
1000        let write = config.all_write_servers();
1001        assert!(write.contains(&"https://legacy.server".to_string()));
1002        assert!(write.contains(&"https://upload.iris.to".to_string()));
1003    }
1004
1005    #[test]
1006    fn test_blossom_servers_fall_back_to_defaults_when_explicitly_empty() {
1007        let config = BlossomConfig {
1008            enabled: true,
1009            servers: Vec::new(),
1010            read_servers: Vec::new(),
1011            write_servers: Vec::new(),
1012            max_upload_mb: default_max_upload_mb(),
1013        };
1014
1015        let read = config.all_read_servers();
1016        let mut expected = default_read_servers();
1017        expected.extend(default_write_servers());
1018        expected.sort();
1019        expected.dedup();
1020        assert_eq!(read, expected);
1021
1022        let write = config.all_write_servers();
1023        assert_eq!(write, default_write_servers());
1024    }
1025
1026    #[test]
1027    fn test_disabled_sources_preserve_lists_but_return_no_active_endpoints() {
1028        let nostr = NostrConfig {
1029            enabled: false,
1030            relays: vec!["wss://relay.example".to_string()],
1031            ..NostrConfig::default()
1032        };
1033        assert!(nostr.active_relays().is_empty());
1034
1035        let blossom = BlossomConfig {
1036            enabled: false,
1037            servers: vec!["https://legacy.server".to_string()],
1038            read_servers: vec!["https://read.example".to_string()],
1039            write_servers: vec!["https://write.example".to_string()],
1040            max_upload_mb: default_max_upload_mb(),
1041        };
1042        assert!(blossom.all_read_servers().is_empty());
1043        assert!(blossom.all_write_servers().is_empty());
1044    }
1045}