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