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