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