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