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::net::{IpAddr, SocketAddr};
7use std::path::Path;
8
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10pub struct Config {
11    #[serde(default)]
12    pub server: ServerConfig,
13    #[serde(default)]
14    pub storage: StorageConfig,
15    #[serde(default)]
16    pub nostr: NostrConfig,
17    #[serde(default)]
18    pub blossom: BlossomConfig,
19    #[serde(default)]
20    pub sync: SyncConfig,
21    #[serde(default)]
22    pub cashu: CashuConfig,
23    #[serde(default)]
24    pub updater: UpdaterConfig,
25}
26
27/// htree self-update preferences. Auto-check is on by default — htree
28/// quietly checks for a newer published binary at most once per
29/// `check_interval_hours` and prints a one-liner to stderr when one
30/// exists. `auto_install` is off by default; flip it on to install in
31/// the background and print the result.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct UpdaterConfig {
34    #[serde(default = "default_auto_check")]
35    pub auto_check: bool,
36    #[serde(default)]
37    pub auto_install: bool,
38    #[serde(default = "default_check_interval_hours")]
39    pub check_interval_hours: u32,
40}
41
42impl Default for UpdaterConfig {
43    fn default() -> Self {
44        Self {
45            auto_check: default_auto_check(),
46            auto_install: false,
47            check_interval_hours: default_check_interval_hours(),
48        }
49    }
50}
51
52fn default_auto_check() -> bool {
53    true
54}
55
56fn default_check_interval_hours() -> u32 {
57    24
58}
59
60#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "kebab-case")]
62pub enum ServerMode {
63    #[default]
64    Normal,
65    #[serde(alias = "signal-only")]
66    Assist,
67}
68
69impl ServerMode {
70    pub const fn as_str(self) -> &'static str {
71        match self {
72            Self::Normal => "normal",
73            Self::Assist => "assist",
74        }
75    }
76
77    pub const fn hash_get_enabled(self) -> bool {
78        matches!(self, Self::Normal)
79    }
80
81    pub const fn background_services_enabled(self) -> bool {
82        matches!(self, Self::Normal)
83    }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct ServerConfig {
88    #[serde(default)]
89    pub mode: ServerMode,
90    #[serde(default = "default_bind_address")]
91    pub bind_address: String,
92    #[serde(default = "default_enable_auth")]
93    pub enable_auth: bool,
94    /// Enable FIPS-backed Hashtree blob exchange.
95    #[serde(default = "default_enable_fips")]
96    pub enable_fips: bool,
97    /// FIPS discovery/signaling scope for Hashtree peers.
98    #[serde(default = "default_fips_discovery_scope")]
99    pub fips_discovery_scope: String,
100    /// Maximum unconfigured peers allowed to enter Nostr discovery concurrently.
101    /// Zero keeps discovery restricted to configured/social-graph peers.
102    #[serde(default)]
103    pub fips_open_discovery_max_pending: usize,
104    /// Optional loopback rendezvous address override for isolated local stacks.
105    /// Unset uses the FIPS well-known address (127.0.0.1:21211).
106    #[serde(default)]
107    pub fips_local_rendezvous_addr: Option<String>,
108    /// Enable FIPS LAN/mDNS peer discovery.
109    #[serde(default = "default_enable_fips_lan_discovery")]
110    pub enable_fips_lan_discovery: bool,
111    /// FIPS Nostr relays used for discovery adverts and encrypted signaling.
112    /// Unset uses active [nostr].relays plus FIPS defaults; an explicit empty
113    /// list disables relay discovery.
114    #[serde(default)]
115    pub fips_relays: Option<Vec<String>>,
116    /// Always-configured Hashtree FIPS peers. These are useful for origin/cache
117    /// pairs that should connect immediately without waiting for discovery.
118    #[serde(
119        default,
120        alias = "preconfigured_fips_peers",
121        alias = "fips_static_peers"
122    )]
123    pub fips_peers: Vec<ConfiguredFipsPeer>,
124    /// Enable ordinary FIPS UDP endpoint transport.
125    #[serde(default = "default_enable_fips_udp")]
126    pub enable_fips_udp: bool,
127    /// FIPS UDP bind address. Empty/default lets the kernel pick an ephemeral port.
128    #[serde(default)]
129    pub fips_udp_bind_addr: Option<String>,
130    /// Advertise the FIPS UDP endpoint as directly reachable.
131    #[serde(default)]
132    pub fips_udp_public: bool,
133    /// Explicit FIPS UDP address to advertise when `fips_udp_public` is true.
134    #[serde(default)]
135    pub fips_udp_external_addr: Option<String>,
136    /// Enable FIPS WebRTC endpoint transport.
137    #[serde(default = "default_enable_fips_webrtc")]
138    pub enable_fips_webrtc: bool,
139    /// Host-local Ethernet interfaces for FIPS endpoint transport.
140    #[serde(default)]
141    pub fips_ethernet_interfaces: Vec<String>,
142    /// Allow daemon cache misses to fetch blobs from FIPS peers.
143    #[serde(default = "default_fetch_from_fips_peers", alias = "http_fips_fetch")]
144    pub fetch_from_fips_peers: bool,
145    /// How long one FIPS blob request waits for a valid response.
146    #[serde(default = "default_fips_request_timeout_ms")]
147    pub fips_request_timeout_ms: u64,
148    /// Allow anyone with valid Nostr auth to write (default: true)
149    /// When false, only social graph members can write
150    #[serde(default = "default_public_writes")]
151    pub public_writes: bool,
152    /// Allow public plaintext reads from mutable npub routes (default: true)
153    /// When false, only configured or social graph approved npubs are served.
154    #[serde(default = "default_public_plaintext_reads")]
155    pub public_plaintext_reads: bool,
156    /// Allow public access to social graph snapshot endpoint (default: false)
157    #[serde(default = "default_socialgraph_snapshot_public")]
158    pub socialgraph_snapshot_public: bool,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct ConfiguredFipsPeer {
163    pub npub: String,
164    #[serde(default)]
165    pub udp_addresses: Vec<String>,
166}
167
168fn default_public_writes() -> bool {
169    true
170}
171
172fn default_public_plaintext_reads() -> bool {
173    false
174}
175
176fn default_socialgraph_snapshot_public() -> bool {
177    false
178}
179
180impl ServerConfig {
181    pub fn resolved_fips_relays(&self, active_nostr_relays: &[String]) -> Vec<String> {
182        match &self.fips_relays {
183            // An explicit list is authoritative. This is required for private
184            // relay deployments and deterministic local test networks; adding
185            // public bootstrap relays here leaks signaling outside that scope.
186            Some(relays) => normalize_fips_signal_relays(relays),
187            None => merge_fips_signal_relays(active_nostr_relays),
188        }
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    normalize_fips_signal_relays(
196        &configured
197            .iter()
198            .cloned()
199            .chain(DEFAULT_FIPS_SIGNAL_RELAYS.into_iter().map(str::to_string))
200            .collect::<Vec<_>>(),
201    )
202}
203
204fn normalize_fips_signal_relays(configured: &[String]) -> Vec<String> {
205    let mut relays = Vec::new();
206    for relay in configured {
207        let normalized = relay.trim().trim_end_matches('/').to_string();
208        if normalized.is_empty() || relays.contains(&normalized) {
209            continue;
210        }
211        relays.push(normalized);
212    }
213    relays
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct StorageConfig {
218    #[serde(default = "default_data_dir")]
219    pub data_dir: String,
220    #[serde(default = "default_max_size_gb")]
221    pub max_size_gb: u64,
222    #[serde(default = "default_storage_evict_orphans")]
223    pub evict_orphans: bool,
224    /// Optional S3/R2 backend for blob storage
225    #[serde(default)]
226    pub s3: Option<S3Config>,
227}
228
229/// S3-compatible storage configuration (works with AWS S3, Cloudflare R2, MinIO, etc.)
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct S3Config {
232    /// S3 endpoint URL (e.g., "https://<account_id>.r2.cloudflarestorage.com" for R2)
233    pub endpoint: String,
234    /// S3 bucket name
235    pub bucket: String,
236    /// Optional key prefix for all blobs (e.g., "blobs/")
237    #[serde(default)]
238    pub prefix: Option<String>,
239    /// AWS region (use "auto" for R2)
240    #[serde(default = "default_s3_region")]
241    pub region: String,
242    /// Access key ID (can also be set via AWS_ACCESS_KEY_ID env var)
243    #[serde(default)]
244    pub access_key: Option<String>,
245    /// Secret access key (can also be set via AWS_SECRET_ACCESS_KEY env var)
246    #[serde(default)]
247    pub secret_key: Option<String>,
248    /// Public URL for serving blobs (optional, for generating public URLs)
249    #[serde(default)]
250    pub public_url: Option<String>,
251}
252
253fn default_s3_region() -> String {
254    "auto".to_string()
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
258#[serde(rename_all = "kebab-case")]
259pub enum NostrEventTransport {
260    #[default]
261    Relay,
262    FipsLocalOnly,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct NostrConfig {
267    #[serde(default = "default_nostr_enabled")]
268    pub enabled: bool,
269    #[serde(default = "default_relays")]
270    pub relays: Vec<String>,
271    /// Provider used for Hashtree root/site event lookup and publication.
272    #[serde(default)]
273    pub event_transport: NostrEventTransport,
274    /// List of npubs allowed to write (blossom uploads). If empty, uses public_writes setting.
275    #[serde(default)]
276    pub allowed_npubs: Vec<String>,
277    /// Social graph root pubkey (npub). Defaults to own key if not set.
278    #[serde(default)]
279    pub socialgraph_root: Option<String>,
280    /// Pubkeys to seed into contacts.json when a new identity is initialized.
281    /// Set to [] to opt out.
282    #[serde(default = "default_nostr_bootstrap_follows")]
283    pub bootstrap_follows: Vec<String>,
284    /// How many hops to crawl the social graph (default: 2)
285    #[serde(default = "default_social_graph_crawl_depth", alias = "crawl_depth")]
286    pub social_graph_crawl_depth: u32,
287    /// Max follow distance to mirror into public event/profile indexes.
288    /// Defaults to social_graph_crawl_depth when unset.
289    #[serde(default)]
290    pub mirror_max_follow_distance: Option<u32>,
291    /// Max follow distance for write access (default: 3)
292    #[serde(default = "default_max_write_distance")]
293    pub max_write_distance: u32,
294    /// Max size for the trusted social graph store in GB (default: 10)
295    #[serde(default = "default_nostr_db_max_size_gb")]
296    pub db_max_size_gb: u64,
297    /// Max size for the social graph spambox in GB (default: 1)
298    /// Set to 0 for memory-only spambox (no on-disk DB)
299    #[serde(default = "default_nostr_spambox_max_size_gb")]
300    pub spambox_max_size_gb: u64,
301    /// Require relays to support NIP-77 negentropy for mirror history sync.
302    #[serde(default)]
303    pub negentropy_only: bool,
304    /// Threshold for treating a user as overmuted in mirrored profile indexing/search.
305    #[serde(default = "default_nostr_overmute_threshold")]
306    pub overmute_threshold: f64,
307    /// Kinds mirrored from upstream relays for the trusted hashtree index.
308    #[serde(default = "default_nostr_mirror_kinds")]
309    pub mirror_kinds: Vec<u16>,
310    /// Authors per ordinary history chunk and between durable mirror-root publications.
311    /// Archive sync caps each durable chunk to one configured relay author batch.
312    #[serde(default = "default_nostr_history_sync_author_chunk_size")]
313    pub history_sync_author_chunk_size: usize,
314    /// Maximum mirrored history events to fetch per author during history sync.
315    #[serde(default = "default_nostr_history_sync_per_author_event_limit")]
316    pub history_sync_per_author_event_limit: usize,
317    /// Run a catch-up history sync after relay reconnects.
318    #[serde(default = "default_nostr_history_sync_on_reconnect")]
319    pub history_sync_on_reconnect: bool,
320    /// Legacy maximum follow distance for complete kind-1 and kind-30023 text history.
321    /// Set to null to disable the legacy text-only archive pass.
322    #[serde(default = "default_nostr_full_text_note_history_follow_distance")]
323    pub full_text_note_history_follow_distance: Option<u32>,
324    /// Legacy maximum relay pages per author and text kind for startup history fetches.
325    /// Set to 0 to disable the legacy text-only archive pass.
326    #[serde(default = "default_nostr_full_text_note_history_max_relay_pages")]
327    pub full_text_note_history_max_relay_pages: usize,
328    /// Maximum follow distance for the complete post, deletion, repost, reaction, zap,
329    /// comment, picture, and article archive.
330    /// Used instead of the legacy text-only settings when archive_history_max_relay_pages
331    /// is greater than zero. Set to null to disable the complete archive pass.
332    #[serde(default = "default_nostr_archive_history_follow_distance")]
333    pub archive_history_follow_distance: Option<u32>,
334    /// Maximum relay pages per author and kind for the complete startup archive pass.
335    /// Set to 0 to leave the new archive pass disabled; bounded recent sync still runs.
336    #[serde(default = "default_nostr_archive_history_max_relay_pages")]
337    pub archive_history_max_relay_pages: usize,
338    /// Enable experimental decentralized Nostr event pubsub when compiled with
339    /// the experimental-decentralized-pubsub feature.
340    #[serde(default, alias = "relayless_pubsub")]
341    pub decentralized_pubsub: bool,
342    /// Maximum encoded Nostr event frame accepted on decentralized pubsub.
343    /// Values above the authenticated FIPS datagram limit are clamped.
344    #[serde(default = "default_nostr_decentralized_pubsub_max_event_bytes")]
345    pub decentralized_pubsub_max_event_bytes: usize,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct BlossomConfig {
350    #[serde(default = "default_blossom_enabled")]
351    pub enabled: bool,
352    /// File servers for push/pull (legacy, both read and write)
353    #[serde(default)]
354    pub servers: Vec<String>,
355    /// Read-only file servers (fallback for fetching content)
356    #[serde(default = "default_read_servers")]
357    pub read_servers: Vec<String>,
358    /// Write-enabled file servers (for uploading)
359    #[serde(default = "default_write_servers")]
360    pub write_servers: Vec<String>,
361    /// Maximum upload size in MB (default: 5)
362    #[serde(default = "default_max_upload_mb")]
363    pub max_upload_mb: u64,
364    /// Require public Blossom and peer-fetched cached blobs to look encrypted.
365    #[serde(default = "default_require_random_untrusted_ingest")]
366    pub require_random_untrusted_ingest: bool,
367    /// Return from Blossom PUT /upload after validation and queue local storage
368    /// writes in the background.
369    #[serde(default = "default_optimistic_uploads")]
370    pub optimistic_uploads: bool,
371    /// Background write-behind targets for blobs accepted by this server.
372    /// Useful for hot-cache origins that should ACK local writes quickly and
373    /// replicate them to a larger origin without blocking the client.
374    #[serde(
375        default,
376        alias = "write_behind_servers",
377        alias = "mirror_write_servers"
378    )]
379    pub replicate_servers: Vec<String>,
380    /// Maximum in-memory upload body bytes waiting for background replication.
381    #[serde(default = "default_replicate_queue_mb")]
382    pub replicate_queue_mb: u64,
383}
384
385impl BlossomConfig {
386    pub fn all_read_servers(&self) -> Vec<String> {
387        if !self.enabled {
388            return Vec::new();
389        }
390        let mut servers = self.servers.clone();
391        servers.extend(self.read_servers.clone());
392        servers.extend(self.write_servers.clone());
393        if servers.is_empty() {
394            servers = default_read_servers();
395            servers.extend(default_write_servers());
396        }
397        servers.sort();
398        servers.dedup();
399        servers
400    }
401
402    /// Read alternatives for the daemon's HTTP server, excluding the server
403    /// itself. CLI clients may intentionally use the local daemon as their
404    /// configured Blossom endpoint, but the daemon must not recursively query
405    /// that endpoint when a blob is absent locally.
406    pub fn upstream_read_servers(&self, bind_address: &str) -> Vec<String> {
407        let Some((bind_host, bind_port)) = parse_bind_authority(bind_address) else {
408            return self.all_read_servers();
409        };
410
411        self.all_read_servers()
412            .into_iter()
413            .filter(|server| !is_bound_http_server(server, &bind_host, bind_port))
414            .collect()
415    }
416
417    pub fn all_write_servers(&self) -> Vec<String> {
418        if !self.enabled {
419            return Vec::new();
420        }
421        let mut servers = self.servers.clone();
422        servers.extend(self.write_servers.clone());
423        if servers.is_empty() {
424            servers = default_write_servers();
425        }
426        servers.sort();
427        servers.dedup();
428        servers
429    }
430}
431
432fn parse_bind_authority(bind_address: &str) -> Option<(String, u16)> {
433    if let Ok(address) = bind_address.parse::<SocketAddr>() {
434        return Some((address.ip().to_string(), address.port()));
435    }
436
437    let url = reqwest::Url::parse(&format!("http://{bind_address}")).ok()?;
438    Some((url.host_str()?.to_string(), url.port_or_known_default()?))
439}
440
441fn is_bound_http_server(server: &str, bind_host: &str, bind_port: u16) -> bool {
442    let Ok(url) = reqwest::Url::parse(server) else {
443        return false;
444    };
445    if url.scheme() != "http" || url.port_or_known_default() != Some(bind_port) {
446        return false;
447    }
448    let Some(upstream_host) = url.host_str() else {
449        return false;
450    };
451    let upstream_host = upstream_host
452        .strip_prefix('[')
453        .and_then(|host| host.strip_suffix(']'))
454        .unwrap_or(upstream_host);
455    if upstream_host.eq_ignore_ascii_case(bind_host) {
456        return true;
457    }
458
459    let bind_ip = bind_host.parse::<IpAddr>().ok();
460    let upstream_ip = upstream_host.parse::<IpAddr>().ok();
461    let upstream_is_localhost = upstream_host.eq_ignore_ascii_case("localhost");
462    match bind_ip {
463        Some(ip) if ip.is_unspecified() => {
464            upstream_is_localhost
465                || upstream_ip
466                    .is_some_and(|candidate| candidate.is_loopback() || candidate.is_unspecified())
467        }
468        Some(ip) if ip.is_loopback() => {
469            upstream_is_localhost || upstream_ip.is_some_and(|candidate| candidate.is_loopback())
470        }
471        _ => false,
472    }
473}
474
475impl NostrConfig {
476    pub fn active_relays(&self) -> Vec<String> {
477        if self.enabled && self.event_transport == NostrEventTransport::Relay {
478            self.relays.clone()
479        } else {
480            Vec::new()
481        }
482    }
483
484    pub fn decentralized_pubsub_enabled(&self) -> bool {
485        self.enabled
486            && self.decentralized_pubsub
487            && cfg!(feature = "experimental-decentralized-pubsub")
488    }
489}
490
491fn default_nostr_decentralized_pubsub_max_event_bytes() -> usize {
492    nostr_pubsub_fips::FIPS_NOSTR_PUBSUB_MAX_DATAGRAM_BYTES
493}
494
495// Keep in sync with hashtree-config/src/lib.rs
496fn default_read_servers() -> Vec<String> {
497    let mut servers = vec![
498        "https://blossom.primal.net".to_string(),
499        "https://cdn.iris.to".to_string(),
500    ];
501    servers.sort();
502    servers
503}
504
505fn default_write_servers() -> Vec<String> {
506    vec!["https://upload.iris.to".to_string()]
507}
508
509fn default_max_upload_mb() -> u64 {
510    5
511}
512
513fn default_require_random_untrusted_ingest() -> bool {
514    true
515}
516
517fn default_optimistic_uploads() -> bool {
518    false
519}
520
521fn default_replicate_queue_mb() -> u64 {
522    256
523}
524
525fn default_nostr_enabled() -> bool {
526    true
527}
528
529fn default_blossom_enabled() -> bool {
530    true
531}
532
533#[derive(Debug, Clone, Serialize, Deserialize)]
534pub struct SyncConfig {
535    /// Enable background sync (auto-pull trees)
536    #[serde(default = "default_sync_enabled")]
537    pub enabled: bool,
538    /// Sync own trees (subscribed via Nostr)
539    #[serde(default = "default_sync_own")]
540    pub sync_own: bool,
541    /// Sync followed users' public trees
542    #[serde(default = "default_sync_followed")]
543    pub sync_followed: bool,
544    /// Max concurrent sync tasks
545    #[serde(default = "default_max_concurrent")]
546    pub max_concurrent: usize,
547    /// Blossom request timeout in milliseconds
548    #[serde(default = "default_blossom_timeout_ms")]
549    pub blossom_timeout_ms: u64,
550}
551
552#[derive(Debug, Clone, Default, Serialize, Deserialize)]
553pub struct CashuConfig {
554    /// Cashu mint base URLs available to explicit wallet operations.
555    #[serde(default)]
556    pub accepted_mints: Vec<String>,
557    /// Default mint to use for wallet operations.
558    #[serde(default)]
559    pub default_mint: Option<String>,
560}
561
562fn default_sync_enabled() -> bool {
563    true
564}
565
566fn default_sync_own() -> bool {
567    true
568}
569
570fn default_sync_followed() -> bool {
571    true
572}
573
574fn default_max_concurrent() -> usize {
575    3
576}
577
578fn default_blossom_timeout_ms() -> u64 {
579    10000
580}
581
582fn default_social_graph_crawl_depth() -> u32 {
583    2
584}
585
586fn default_nostr_bootstrap_follows() -> Vec<String> {
587    vec![hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB.to_string()]
588}
589
590fn default_max_write_distance() -> u32 {
591    3
592}
593
594fn default_nostr_db_max_size_gb() -> u64 {
595    10
596}
597
598fn default_nostr_spambox_max_size_gb() -> u64 {
599    1
600}
601
602fn default_nostr_history_sync_on_reconnect() -> bool {
603    true
604}
605
606fn default_nostr_overmute_threshold() -> f64 {
607    1.0
608}
609
610fn default_nostr_mirror_kinds() -> Vec<u16> {
611    vec![
612        0, 1, 3, 5, 6, 7, 16, 20, 1_111, 9_735, 10_000, 30_000, 30_023,
613    ]
614}
615
616fn default_nostr_history_sync_author_chunk_size() -> usize {
617    5_000
618}
619
620fn default_nostr_history_sync_per_author_event_limit() -> usize {
621    256
622}
623
624fn default_nostr_full_text_note_history_follow_distance() -> Option<u32> {
625    Some(2)
626}
627
628fn default_nostr_full_text_note_history_max_relay_pages() -> usize {
629    0
630}
631
632fn default_nostr_archive_history_follow_distance() -> Option<u32> {
633    Some(2)
634}
635
636fn default_nostr_archive_history_max_relay_pages() -> usize {
637    0
638}
639
640fn default_relays() -> Vec<String> {
641    vec![
642        "wss://nos.lol".to_string(),
643        "wss://relay.snort.social".to_string(),
644        "wss://temp.iris.to".to_string(),
645    ]
646}
647
648fn default_bind_address() -> String {
649    "127.0.0.1:8080".to_string()
650}
651
652fn default_enable_auth() -> bool {
653    true
654}
655
656fn default_enable_fips() -> bool {
657    true
658}
659
660fn default_enable_fips_lan_discovery() -> bool {
661    true
662}
663
664fn default_fips_discovery_scope() -> String {
665    hashtree_fips_transport::DEFAULT_FIPS_DISCOVERY_SCOPE.to_string()
666}
667
668fn default_enable_fips_udp() -> bool {
669    true
670}
671
672fn default_enable_fips_webrtc() -> bool {
673    cfg!(feature = "fips-webrtc")
674}
675
676fn default_fetch_from_fips_peers() -> bool {
677    true
678}
679
680fn default_fips_request_timeout_ms() -> u64 {
681    5_500
682}
683
684fn default_data_dir() -> String {
685    hashtree_config::get_hashtree_dir()
686        .join("data")
687        .to_string_lossy()
688        .to_string()
689}
690
691fn default_max_size_gb() -> u64 {
692    10
693}
694
695fn default_storage_evict_orphans() -> bool {
696    true
697}
698
699impl Default for ServerConfig {
700    fn default() -> Self {
701        Self {
702            mode: ServerMode::default(),
703            bind_address: default_bind_address(),
704            enable_auth: default_enable_auth(),
705            enable_fips: default_enable_fips(),
706            fips_discovery_scope: default_fips_discovery_scope(),
707            fips_open_discovery_max_pending: 0,
708            fips_local_rendezvous_addr: None,
709            enable_fips_lan_discovery: default_enable_fips_lan_discovery(),
710            fips_relays: None,
711            fips_peers: Vec::new(),
712            enable_fips_udp: default_enable_fips_udp(),
713            fips_udp_bind_addr: None,
714            fips_udp_public: false,
715            fips_udp_external_addr: None,
716            enable_fips_webrtc: default_enable_fips_webrtc(),
717            fips_ethernet_interfaces: Vec::new(),
718            fetch_from_fips_peers: default_fetch_from_fips_peers(),
719            fips_request_timeout_ms: default_fips_request_timeout_ms(),
720            public_writes: default_public_writes(),
721            public_plaintext_reads: default_public_plaintext_reads(),
722            socialgraph_snapshot_public: default_socialgraph_snapshot_public(),
723        }
724    }
725}
726
727impl Default for StorageConfig {
728    fn default() -> Self {
729        Self {
730            data_dir: default_data_dir(),
731            max_size_gb: default_max_size_gb(),
732            evict_orphans: default_storage_evict_orphans(),
733            s3: None,
734        }
735    }
736}
737
738impl Default for NostrConfig {
739    fn default() -> Self {
740        Self {
741            enabled: default_nostr_enabled(),
742            relays: default_relays(),
743            event_transport: NostrEventTransport::default(),
744            allowed_npubs: Vec::new(),
745            socialgraph_root: None,
746            bootstrap_follows: default_nostr_bootstrap_follows(),
747            social_graph_crawl_depth: default_social_graph_crawl_depth(),
748            mirror_max_follow_distance: None,
749            max_write_distance: default_max_write_distance(),
750            db_max_size_gb: default_nostr_db_max_size_gb(),
751            spambox_max_size_gb: default_nostr_spambox_max_size_gb(),
752            negentropy_only: false,
753            overmute_threshold: default_nostr_overmute_threshold(),
754            mirror_kinds: default_nostr_mirror_kinds(),
755            history_sync_author_chunk_size: default_nostr_history_sync_author_chunk_size(),
756            history_sync_per_author_event_limit: default_nostr_history_sync_per_author_event_limit(
757            ),
758            history_sync_on_reconnect: default_nostr_history_sync_on_reconnect(),
759            full_text_note_history_follow_distance:
760                default_nostr_full_text_note_history_follow_distance(),
761            full_text_note_history_max_relay_pages:
762                default_nostr_full_text_note_history_max_relay_pages(),
763            archive_history_follow_distance: default_nostr_archive_history_follow_distance(),
764            archive_history_max_relay_pages: default_nostr_archive_history_max_relay_pages(),
765            decentralized_pubsub: false,
766            decentralized_pubsub_max_event_bytes:
767                default_nostr_decentralized_pubsub_max_event_bytes(),
768        }
769    }
770}
771
772impl Default for BlossomConfig {
773    fn default() -> Self {
774        Self {
775            enabled: default_blossom_enabled(),
776            servers: Vec::new(),
777            read_servers: default_read_servers(),
778            write_servers: default_write_servers(),
779            max_upload_mb: default_max_upload_mb(),
780            require_random_untrusted_ingest: default_require_random_untrusted_ingest(),
781            optimistic_uploads: default_optimistic_uploads(),
782            replicate_servers: Vec::new(),
783            replicate_queue_mb: default_replicate_queue_mb(),
784        }
785    }
786}
787
788impl Default for SyncConfig {
789    fn default() -> Self {
790        Self {
791            enabled: default_sync_enabled(),
792            sync_own: default_sync_own(),
793            sync_followed: default_sync_followed(),
794            max_concurrent: default_max_concurrent(),
795            blossom_timeout_ms: default_blossom_timeout_ms(),
796        }
797    }
798}
799
800impl Config {
801    /// Load config from file, or create default if doesn't exist
802    pub fn load() -> Result<Self> {
803        let config_path = get_config_path();
804
805        if config_path.exists() {
806            let content = fs::read_to_string(&config_path).context("Failed to read config file")?;
807            toml::from_str(&content).context("Failed to parse config file")
808        } else {
809            let config = Config::default();
810            config.save()?;
811            Ok(config)
812        }
813    }
814
815    /// Save config to file
816    pub fn save(&self) -> Result<()> {
817        let config_path = get_config_path();
818
819        // Ensure parent directory exists
820        if let Some(parent) = config_path.parent() {
821            fs::create_dir_all(parent)?;
822        }
823
824        let content = toml::to_string_pretty(self)?;
825        fs::write(&config_path, content)?;
826
827        Ok(())
828    }
829}
830
831// Re-export path functions from hashtree_config
832pub use hashtree_config::{get_auth_cookie_path, get_config_path, get_hashtree_dir, get_keys_path};
833
834fn read_keys_from_path(keys_path: &Path) -> Result<Keys> {
835    let content = fs::read_to_string(keys_path).context("Failed to read keys file")?;
836    let entries = hashtree_config::parse_keys_file(&content);
837    let nsec_str = entries
838        .into_iter()
839        .next()
840        .map(|e| e.secret)
841        .context("Keys file is empty")?;
842    let secret_key = SecretKey::from_bech32(&nsec_str).context("Invalid nsec format")?;
843    Ok(Keys::new(secret_key))
844}
845
846fn seed_identity_defaults_if_needed(data_dir: Option<&Path>, config: Option<&Config>) {
847    if let (Some(data_dir), Some(config)) = (data_dir, config) {
848        let _ = crate::bootstrap::seed_identity_defaults(data_dir, config);
849    }
850}
851
852fn write_keys_to_path(keys_path: &Path, keys: &Keys) -> Result<()> {
853    if let Some(parent) = keys_path.parent() {
854        fs::create_dir_all(parent)?;
855    }
856
857    let nsec = keys
858        .secret_key()
859        .to_bech32()
860        .context("Failed to encode nsec")?;
861    fs::write(keys_path, &nsec)?;
862
863    #[cfg(unix)]
864    {
865        use std::os::unix::fs::PermissionsExt;
866        let perms = fs::Permissions::from_mode(0o600);
867        fs::set_permissions(keys_path, perms)?;
868    }
869
870    Ok(())
871}
872
873/// Generate and save auth cookie if it doesn't exist
874pub fn ensure_auth_cookie() -> Result<(String, String)> {
875    let cookie_path = get_auth_cookie_path();
876
877    if cookie_path.exists() {
878        read_auth_cookie()
879    } else {
880        generate_auth_cookie()
881    }
882}
883
884/// Read existing auth cookie
885pub fn read_auth_cookie() -> Result<(String, String)> {
886    let cookie_path = get_auth_cookie_path();
887    let content = fs::read_to_string(&cookie_path).context("Failed to read auth cookie")?;
888
889    let parts: Vec<&str> = content.trim().split(':').collect();
890    if parts.len() != 2 {
891        anyhow::bail!("Invalid auth cookie format");
892    }
893
894    Ok((parts[0].to_string(), parts[1].to_string()))
895}
896
897/// Ensure keys file exists, generating one if not present
898/// Returns (Keys, was_generated)
899pub fn ensure_keys() -> Result<(Keys, bool)> {
900    let config_dir = get_hashtree_dir();
901    let config = Config::load().ok();
902    let data_dir = config
903        .as_ref()
904        .map(|cfg| Path::new(cfg.storage.data_dir.as_str()));
905    ensure_keys_in(&config_dir, data_dir, config.as_ref())
906}
907
908/// Ensure keys exist inside an explicit config directory.
909/// Returns (Keys, was_generated)
910pub fn ensure_keys_in(
911    config_dir: &Path,
912    data_dir: Option<&Path>,
913    config: Option<&Config>,
914) -> Result<(Keys, bool)> {
915    let keys_path = config_dir.join("keys");
916
917    if keys_path.exists() {
918        Ok((read_keys_from_path(&keys_path)?, false))
919    } else {
920        let keys = generate_keys_in(config_dir, data_dir, config)?;
921        Ok((keys, true))
922    }
923}
924
925/// Read existing keys
926pub fn read_keys() -> Result<Keys> {
927    read_keys_in(&get_hashtree_dir())
928}
929
930/// Read keys from an explicit config directory.
931pub fn read_keys_in(config_dir: &Path) -> Result<Keys> {
932    read_keys_from_path(&config_dir.join("keys"))
933}
934
935/// Get nsec string, ensuring keys file exists (generate if needed)
936/// Returns (nsec_string, was_generated)
937pub fn ensure_keys_string() -> Result<(String, bool)> {
938    let config_dir = get_hashtree_dir();
939    let config = Config::load().ok();
940    let data_dir = config
941        .as_ref()
942        .map(|cfg| Path::new(cfg.storage.data_dir.as_str()));
943    ensure_keys_string_in(&config_dir, data_dir, config.as_ref())
944}
945
946/// Ensure key material exists inside an explicit config directory.
947/// Returns (nsec_string, was_generated)
948pub fn ensure_keys_string_in(
949    config_dir: &Path,
950    data_dir: Option<&Path>,
951    config: Option<&Config>,
952) -> Result<(String, bool)> {
953    let keys_path = config_dir.join("keys");
954
955    if keys_path.exists() {
956        let content = fs::read_to_string(&keys_path).context("Failed to read keys file")?;
957        let entries = hashtree_config::parse_keys_file(&content);
958        let nsec_str = entries
959            .into_iter()
960            .next()
961            .map(|e| e.secret)
962            .context("Keys file is empty")?;
963        Ok((nsec_str, false))
964    } else {
965        let keys = generate_keys_in(config_dir, data_dir, config)?;
966        let nsec = keys
967            .secret_key()
968            .to_bech32()
969            .context("Failed to encode nsec")?;
970        Ok((nsec, true))
971    }
972}
973
974/// Generate new keys and save to file
975pub fn generate_keys() -> Result<Keys> {
976    let config_dir = get_hashtree_dir();
977    let config = Config::load().ok();
978    let data_dir = config
979        .as_ref()
980        .map(|cfg| Path::new(cfg.storage.data_dir.as_str()));
981    generate_keys_in(&config_dir, data_dir, config.as_ref())
982}
983
984/// Generate new keys in an explicit config directory and optionally seed
985/// identity defaults into a caller-owned data directory.
986pub fn generate_keys_in(
987    config_dir: &Path,
988    data_dir: Option<&Path>,
989    config: Option<&Config>,
990) -> Result<Keys> {
991    let keys = Keys::generate();
992    write_keys_to_path(&config_dir.join("keys"), &keys)?;
993    seed_identity_defaults_if_needed(data_dir, config);
994    Ok(keys)
995}
996
997/// Get 32-byte pubkey bytes from Keys.
998pub fn pubkey_bytes(keys: &Keys) -> [u8; 32] {
999    keys.public_key().to_bytes()
1000}
1001
1002/// Parse npub to 32-byte pubkey
1003pub fn parse_npub(npub: &str) -> Result<[u8; 32]> {
1004    use nostr::PublicKey;
1005    let pk = PublicKey::from_bech32(npub).context("Invalid npub format")?;
1006    Ok(pk.to_bytes())
1007}
1008
1009/// Generate new random auth cookie
1010pub fn generate_auth_cookie() -> Result<(String, String)> {
1011    use rand::Rng;
1012
1013    let cookie_path = get_auth_cookie_path();
1014
1015    // Ensure parent directory exists
1016    if let Some(parent) = cookie_path.parent() {
1017        fs::create_dir_all(parent)?;
1018    }
1019
1020    // Generate random credentials
1021    let mut rng = rand::thread_rng();
1022    let username = format!("htree_{}", rng.gen::<u32>());
1023    let password: String = (0..32)
1024        .map(|_| {
1025            let idx = rng.gen_range(0..62);
1026            match idx {
1027                0..=25 => (b'a' + idx) as char,
1028                26..=51 => (b'A' + (idx - 26)) as char,
1029                _ => (b'0' + (idx - 52)) as char,
1030            }
1031        })
1032        .collect();
1033
1034    // Save to file
1035    let content = format!("{}:{}", username, password);
1036    fs::write(&cookie_path, content)?;
1037
1038    // Set permissions to 0600 (owner read/write only)
1039    #[cfg(unix)]
1040    {
1041        use std::os::unix::fs::PermissionsExt;
1042        let perms = fs::Permissions::from_mode(0o600);
1043        fs::set_permissions(&cookie_path, perms)?;
1044    }
1045
1046    Ok((username, password))
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051    use super::*;
1052    use crate::test_support::{test_env_lock, EnvVarGuard};
1053    use tempfile::TempDir;
1054
1055    #[test]
1056    fn test_config_default() {
1057        let config = Config::default();
1058        assert_eq!(config.server.bind_address, "127.0.0.1:8080");
1059        assert!(config.server.enable_auth);
1060        assert!(!config.server.public_plaintext_reads);
1061        assert_eq!(config.storage.max_size_gb, 10);
1062        assert!(config.storage.evict_orphans);
1063        assert!(config.nostr.enabled);
1064        assert!(config
1065            .nostr
1066            .relays
1067            .contains(&"wss://temp.iris.to".to_string()));
1068        assert!(config.blossom.enabled);
1069        assert!(!config.blossom.optimistic_uploads);
1070        assert!(config.blossom.replicate_servers.is_empty());
1071        assert_eq!(config.blossom.replicate_queue_mb, 256);
1072        assert_eq!(config.nostr.social_graph_crawl_depth, 2);
1073        assert_eq!(config.nostr.mirror_max_follow_distance, None);
1074        assert_eq!(config.nostr.max_write_distance, 3);
1075        assert_eq!(config.nostr.db_max_size_gb, 10);
1076        assert_eq!(config.nostr.spambox_max_size_gb, 1);
1077        assert!(!config.nostr.negentropy_only);
1078        assert_eq!(config.nostr.overmute_threshold, 1.0);
1079        assert_eq!(
1080            config.nostr.mirror_kinds,
1081            vec![0, 1, 3, 5, 6, 7, 16, 20, 1_111, 9_735, 10_000, 30_000, 30_023]
1082        );
1083        assert_eq!(config.nostr.history_sync_author_chunk_size, 5_000);
1084        assert_eq!(config.nostr.history_sync_per_author_event_limit, 256);
1085        assert!(config.nostr.history_sync_on_reconnect);
1086        assert_eq!(config.nostr.full_text_note_history_follow_distance, Some(2));
1087        assert_eq!(config.nostr.full_text_note_history_max_relay_pages, 0);
1088        assert_eq!(config.nostr.archive_history_follow_distance, Some(2));
1089        assert_eq!(config.nostr.archive_history_max_relay_pages, 0);
1090        assert!(config.nostr.socialgraph_root.is_none());
1091        assert_eq!(
1092            config.nostr.bootstrap_follows,
1093            vec![hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB.to_string()]
1094        );
1095        assert!(!config.server.socialgraph_snapshot_public);
1096        assert!(config.cashu.accepted_mints.is_empty());
1097        assert!(config.cashu.default_mint.is_none());
1098    }
1099
1100    #[test]
1101    fn test_blossom_optimistic_uploads_deserialize() {
1102        let toml_str = r#"
1103[blossom]
1104optimistic_uploads = true
1105"#;
1106        let config: Config = toml::from_str(toml_str).unwrap();
1107        assert!(config.blossom.optimistic_uploads);
1108        assert!(config.blossom.require_random_untrusted_ingest);
1109    }
1110
1111    #[test]
1112    fn test_blossom_replication_deserialize() {
1113        let toml_str = r#"
1114[blossom]
1115replicate_servers = ["http://127.0.0.1:8081"]
1116replicate_queue_mb = 128
1117"#;
1118        let config: Config = toml::from_str(toml_str).unwrap();
1119        assert_eq!(config.blossom.replicate_servers, ["http://127.0.0.1:8081"]);
1120        assert_eq!(config.blossom.replicate_queue_mb, 128);
1121    }
1122
1123    #[test]
1124    fn test_server_public_plaintext_reads_deserialize() {
1125        let toml_str = r#"
1126[server]
1127public_plaintext_reads = false
1128"#;
1129        let config: Config = toml::from_str(toml_str).unwrap();
1130        assert!(!config.server.public_plaintext_reads);
1131        assert!(config.server.public_writes);
1132    }
1133
1134    #[test]
1135    fn test_nostr_config_deserialize_with_defaults() {
1136        let toml_str = r#"
1137[nostr]
1138relays = ["wss://relay.damus.io"]
1139"#;
1140        let config: Config = toml::from_str(toml_str).unwrap();
1141        assert!(config.nostr.enabled);
1142        assert_eq!(config.nostr.relays, vec!["wss://relay.damus.io"]);
1143        assert!(config.storage.evict_orphans);
1144        assert_eq!(config.nostr.social_graph_crawl_depth, 2);
1145        assert_eq!(config.nostr.mirror_max_follow_distance, None);
1146        assert_eq!(config.nostr.max_write_distance, 3);
1147        assert_eq!(config.nostr.db_max_size_gb, 10);
1148        assert_eq!(config.nostr.spambox_max_size_gb, 1);
1149        assert!(!config.nostr.negentropy_only);
1150        assert_eq!(config.nostr.overmute_threshold, 1.0);
1151        assert_eq!(
1152            config.nostr.mirror_kinds,
1153            vec![0, 1, 3, 5, 6, 7, 16, 20, 1_111, 9_735, 10_000, 30_000, 30_023]
1154        );
1155        assert_eq!(config.nostr.history_sync_author_chunk_size, 5_000);
1156        assert_eq!(config.nostr.history_sync_per_author_event_limit, 256);
1157        assert!(config.nostr.history_sync_on_reconnect);
1158        assert_eq!(config.nostr.full_text_note_history_follow_distance, Some(2));
1159        assert_eq!(config.nostr.full_text_note_history_max_relay_pages, 0);
1160        assert_eq!(config.nostr.archive_history_follow_distance, Some(2));
1161        assert_eq!(config.nostr.archive_history_max_relay_pages, 0);
1162        assert!(config.nostr.socialgraph_root.is_none());
1163        assert_eq!(
1164            config.nostr.bootstrap_follows,
1165            vec![hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB.to_string()]
1166        );
1167    }
1168
1169    #[test]
1170    fn test_nostr_config_deserialize_with_socialgraph() {
1171        let toml_str = r#"
1172[nostr]
1173relays = ["wss://relay.damus.io"]
1174socialgraph_root = "npub1test"
1175bootstrap_follows = []
1176social_graph_crawl_depth = 3
1177mirror_max_follow_distance = 2
1178max_write_distance = 5
1179negentropy_only = true
1180overmute_threshold = 2.5
1181mirror_kinds = [0, 10000]
1182history_sync_author_chunk_size = 250
1183history_sync_per_author_event_limit = 128
1184history_sync_on_reconnect = false
1185full_text_note_history_follow_distance = 1
1186full_text_note_history_max_relay_pages = 64
1187archive_history_follow_distance = 2
1188archive_history_max_relay_pages = 32
1189"#;
1190        let config: Config = toml::from_str(toml_str).unwrap();
1191        assert!(config.nostr.enabled);
1192        assert!(config.storage.evict_orphans);
1193        assert_eq!(config.nostr.socialgraph_root, Some("npub1test".to_string()));
1194        assert!(config.nostr.bootstrap_follows.is_empty());
1195        assert_eq!(config.nostr.social_graph_crawl_depth, 3);
1196        assert_eq!(config.nostr.mirror_max_follow_distance, Some(2));
1197        assert_eq!(config.nostr.max_write_distance, 5);
1198        assert_eq!(config.nostr.db_max_size_gb, 10);
1199        assert_eq!(config.nostr.spambox_max_size_gb, 1);
1200        assert!(config.nostr.negentropy_only);
1201        assert_eq!(config.nostr.overmute_threshold, 2.5);
1202        assert_eq!(config.nostr.mirror_kinds, vec![0, 10_000]);
1203        assert_eq!(config.nostr.history_sync_author_chunk_size, 250);
1204        assert_eq!(config.nostr.history_sync_per_author_event_limit, 128);
1205        assert!(!config.nostr.history_sync_on_reconnect);
1206        assert_eq!(config.nostr.full_text_note_history_follow_distance, Some(1));
1207        assert_eq!(config.nostr.full_text_note_history_max_relay_pages, 64);
1208        assert_eq!(config.nostr.archive_history_follow_distance, Some(2));
1209        assert_eq!(config.nostr.archive_history_max_relay_pages, 32);
1210    }
1211
1212    #[test]
1213    fn test_nostr_config_deserialize_legacy_crawl_depth_alias() {
1214        let toml_str = r#"
1215[nostr]
1216relays = ["wss://relay.damus.io"]
1217crawl_depth = 4
1218"#;
1219        let config: Config = toml::from_str(toml_str).unwrap();
1220        assert_eq!(config.nostr.social_graph_crawl_depth, 4);
1221    }
1222
1223    #[test]
1224    fn test_storage_config_disables_orphan_eviction_when_requested() {
1225        let toml_str = r#"
1226[storage]
1227evict_orphans = false
1228"#;
1229        let config: Config = toml::from_str(toml_str).unwrap();
1230        assert!(!config.storage.evict_orphans);
1231    }
1232
1233    #[test]
1234    fn test_cashu_config_keeps_wallet_mints_and_ignores_removed_paid_blob_settings() {
1235        let toml_str = r#"
1236[cashu]
1237accepted_mints = ["https://mint1.example", "http://127.0.0.1:3338"]
1238default_mint = "https://mint1.example"
1239quote_payment_offer_sat = 5
1240quote_ttl_ms = 2500
1241settlement_timeout_ms = 7000
1242mint_failure_block_threshold = 3
1243peer_suggested_mint_base_cap_sat = 4
1244peer_suggested_mint_success_step_sat = 2
1245peer_suggested_mint_receipt_step_sat = 3
1246peer_suggested_mint_max_cap_sat = 34
1247payment_default_block_threshold = 2
1248chunk_target_bytes = 65536
1249"#;
1250        let config: Config = toml::from_str(toml_str).unwrap();
1251        assert_eq!(
1252            config.cashu.accepted_mints,
1253            vec![
1254                "https://mint1.example".to_string(),
1255                "http://127.0.0.1:3338".to_string()
1256            ]
1257        );
1258        assert_eq!(
1259            config.cashu.default_mint,
1260            Some("https://mint1.example".to_string())
1261        );
1262    }
1263
1264    #[test]
1265    fn test_auth_cookie_generation() -> Result<()> {
1266        let _lock = test_env_lock().blocking_lock();
1267        let temp_dir = TempDir::new()?;
1268        let _guard = EnvVarGuard::set("HTREE_CONFIG_DIR", temp_dir.path());
1269
1270        let (username, password) = generate_auth_cookie()?;
1271
1272        assert!(username.starts_with("htree_"));
1273        assert_eq!(password.len(), 32);
1274
1275        // Verify cookie file exists
1276        let cookie_path = get_auth_cookie_path();
1277        assert!(cookie_path.exists());
1278
1279        // Verify reading works
1280        let (u2, p2) = read_auth_cookie()?;
1281        assert_eq!(username, u2);
1282        assert_eq!(password, p2);
1283
1284        Ok(())
1285    }
1286
1287    #[test]
1288    fn test_blossom_read_servers_include_write_only_servers_as_fresh_fallbacks() {
1289        let config = BlossomConfig {
1290            servers: vec!["https://legacy.server".to_string()],
1291            ..BlossomConfig::default()
1292        };
1293
1294        let read = config.all_read_servers();
1295        assert!(read.contains(&"https://legacy.server".to_string()));
1296        assert!(read.contains(&"https://cdn.iris.to".to_string()));
1297        assert!(read.contains(&"https://blossom.primal.net".to_string()));
1298        assert!(read.contains(&"https://upload.iris.to".to_string()));
1299
1300        let write = config.all_write_servers();
1301        assert!(write.contains(&"https://legacy.server".to_string()));
1302        assert!(write.contains(&"https://upload.iris.to".to_string()));
1303    }
1304
1305    #[test]
1306    fn daemon_blossom_upstreams_exclude_its_own_loopback_http_endpoint() {
1307        let config = BlossomConfig {
1308            servers: Vec::new(),
1309            read_servers: vec![
1310                "http://127.0.0.1:19092".to_string(),
1311                "http://localhost:19092/".to_string(),
1312                "http://127.0.0.1:19093".to_string(),
1313                "https://127.0.0.1:19092".to_string(),
1314                "https://read.example".to_string(),
1315            ],
1316            write_servers: Vec::new(),
1317            ..BlossomConfig::default()
1318        };
1319
1320        let upstreams = config.upstream_read_servers("127.0.0.1:19092");
1321
1322        assert!(!upstreams
1323            .iter()
1324            .any(|server| server == "http://127.0.0.1:19092"));
1325        assert!(!upstreams
1326            .iter()
1327            .any(|server| server == "http://localhost:19092/"));
1328        assert!(upstreams
1329            .iter()
1330            .any(|server| server == "http://127.0.0.1:19093"));
1331        assert!(upstreams
1332            .iter()
1333            .any(|server| server == "https://127.0.0.1:19092"));
1334        assert!(upstreams
1335            .iter()
1336            .any(|server| server == "https://read.example"));
1337    }
1338
1339    #[test]
1340    fn wildcard_daemon_bind_excludes_loopback_self_but_keeps_remote_upstreams() {
1341        let config = BlossomConfig {
1342            servers: Vec::new(),
1343            read_servers: vec![
1344                "http://localhost:8080".to_string(),
1345                "http://[::1]:8080".to_string(),
1346                "http://192.0.2.10:8080".to_string(),
1347            ],
1348            write_servers: Vec::new(),
1349            ..BlossomConfig::default()
1350        };
1351
1352        let upstreams = config.upstream_read_servers("0.0.0.0:8080");
1353
1354        assert!(!upstreams
1355            .iter()
1356            .any(|server| server == "http://localhost:8080"));
1357        assert!(!upstreams.iter().any(|server| server == "http://[::1]:8080"));
1358        assert!(upstreams
1359            .iter()
1360            .any(|server| server == "http://192.0.2.10:8080"));
1361    }
1362
1363    #[test]
1364    fn test_blossom_servers_fall_back_to_defaults_when_explicitly_empty() {
1365        let config = BlossomConfig {
1366            enabled: true,
1367            servers: Vec::new(),
1368            read_servers: Vec::new(),
1369            write_servers: Vec::new(),
1370            max_upload_mb: default_max_upload_mb(),
1371            require_random_untrusted_ingest: default_require_random_untrusted_ingest(),
1372            optimistic_uploads: default_optimistic_uploads(),
1373            replicate_servers: Vec::new(),
1374            replicate_queue_mb: default_replicate_queue_mb(),
1375        };
1376
1377        let read = config.all_read_servers();
1378        let mut expected = default_read_servers();
1379        expected.extend(default_write_servers());
1380        expected.sort();
1381        expected.dedup();
1382        assert_eq!(read, expected);
1383
1384        let write = config.all_write_servers();
1385        assert_eq!(write, default_write_servers());
1386    }
1387
1388    #[test]
1389    fn test_disabled_sources_preserve_lists_but_return_no_active_endpoints() {
1390        let nostr = NostrConfig {
1391            enabled: false,
1392            relays: vec!["wss://relay.example".to_string()],
1393            ..NostrConfig::default()
1394        };
1395        assert!(nostr.active_relays().is_empty());
1396
1397        let blossom = BlossomConfig {
1398            enabled: false,
1399            servers: vec!["https://legacy.server".to_string()],
1400            read_servers: vec!["https://read.example".to_string()],
1401            write_servers: vec!["https://write.example".to_string()],
1402            max_upload_mb: default_max_upload_mb(),
1403            require_random_untrusted_ingest: default_require_random_untrusted_ingest(),
1404            optimistic_uploads: default_optimistic_uploads(),
1405            replicate_servers: Vec::new(),
1406            replicate_queue_mb: default_replicate_queue_mb(),
1407        };
1408        assert!(blossom.all_read_servers().is_empty());
1409        assert!(blossom.all_write_servers().is_empty());
1410    }
1411
1412    #[test]
1413    fn fips_local_only_event_transport_never_exposes_direct_relays() {
1414        let nostr = NostrConfig {
1415            relays: vec!["wss://must-not-open.example".to_string()],
1416            event_transport: NostrEventTransport::FipsLocalOnly,
1417            ..NostrConfig::default()
1418        };
1419
1420        assert!(nostr.active_relays().is_empty());
1421    }
1422
1423    #[test]
1424    fn nostr_decentralized_pubsub_requires_config_and_feature() {
1425        let default_nostr = NostrConfig::default();
1426        assert!(!default_nostr.decentralized_pubsub);
1427        assert!(!default_nostr.decentralized_pubsub_enabled());
1428
1429        let enabled: NostrConfig = toml::from_str("decentralized_pubsub = true")
1430            .expect("parse decentralized pubsub nostr config");
1431        assert!(enabled.decentralized_pubsub);
1432        assert_eq!(
1433            enabled.decentralized_pubsub_max_event_bytes,
1434            nostr_pubsub_fips::FIPS_NOSTR_PUBSUB_MAX_DATAGRAM_BYTES
1435        );
1436        assert_eq!(
1437            enabled.decentralized_pubsub_enabled(),
1438            cfg!(feature = "experimental-decentralized-pubsub")
1439        );
1440
1441        let disabled: NostrConfig = toml::from_str(
1442            r#"
1443enabled = false
1444decentralized_pubsub = true
1445"#,
1446        )
1447        .expect("parse disabled decentralized pubsub nostr config");
1448        assert!(!disabled.decentralized_pubsub_enabled());
1449
1450        let alias: NostrConfig =
1451            toml::from_str("relayless_pubsub = true").expect("parse compatibility pubsub alias");
1452        assert!(alias.decentralized_pubsub);
1453
1454        let tuned: NostrConfig = toml::from_str(
1455            r#"
1456decentralized_pubsub = true
1457decentralized_pubsub_max_event_bytes = 4096
1458"#,
1459        )
1460        .expect("parse tuned decentralized pubsub config");
1461        assert_eq!(tuned.decentralized_pubsub_max_event_bytes, 4096);
1462    }
1463
1464    #[test]
1465    fn server_defaults_enable_fips_udp_and_feature_gated_webrtc() {
1466        let server = ServerConfig::default();
1467
1468        assert!(server.enable_fips);
1469        assert!(server.enable_fips_udp);
1470        assert!(server.fips_udp_bind_addr.is_none());
1471        assert!(!server.fips_udp_public);
1472        assert!(server.fips_udp_external_addr.is_none());
1473        assert_eq!(server.enable_fips_webrtc, cfg!(feature = "fips-webrtc"));
1474        assert!(server.fips_ethernet_interfaces.is_empty());
1475        assert!(server.fetch_from_fips_peers);
1476        assert!(server.fips_relays.is_none());
1477        assert!(server.fips_peers.is_empty());
1478        assert_eq!(server.fips_discovery_scope, "fips-overlay-v1");
1479        assert_eq!(server.fips_open_discovery_max_pending, 0);
1480        assert!(server.fips_local_rendezvous_addr.is_none());
1481        assert!(server.enable_fips_lan_discovery);
1482        assert_eq!(server.fips_request_timeout_ms, 5_500);
1483    }
1484
1485    #[test]
1486    fn server_config_reads_fips_overrides() {
1487        let config: Config = toml::from_str(
1488            r#"
1489[server]
1490enable_fips = true
1491fips_discovery_scope = "test-hashtree"
1492fips_open_discovery_max_pending = 32
1493fips_local_rendezvous_addr = "127.0.0.1:32111"
1494enable_fips_lan_discovery = false
1495fips_relays = ["wss://fips.example"]
1496fips_peers = [
1497  { npub = "npub1origin", udp_addresses = ["udp:192.0.2.10:2121"] },
1498  { npub = "npub1cache" },
1499]
1500enable_fips_udp = false
1501fips_udp_bind_addr = "0.0.0.0:2121"
1502fips_udp_public = true
1503fips_udp_external_addr = "198.19.77.10:2121"
1504enable_fips_webrtc = true
1505fips_ethernet_interfaces = ["eth0"]
1506fetch_from_fips_peers = false
1507fips_request_timeout_ms = 42
1508"#,
1509        )
1510        .unwrap();
1511
1512        assert!(config.server.enable_fips);
1513        assert_eq!(config.server.fips_discovery_scope, "test-hashtree");
1514        assert_eq!(config.server.fips_open_discovery_max_pending, 32);
1515        assert_eq!(
1516            config.server.fips_local_rendezvous_addr.as_deref(),
1517            Some("127.0.0.1:32111")
1518        );
1519        assert!(!config.server.enable_fips_lan_discovery);
1520        assert_eq!(
1521            config.server.fips_relays,
1522            Some(vec!["wss://fips.example".to_string()])
1523        );
1524        assert_eq!(
1525            config.server.fips_peers,
1526            [
1527                ConfiguredFipsPeer {
1528                    npub: "npub1origin".to_string(),
1529                    udp_addresses: vec!["udp:192.0.2.10:2121".to_string()],
1530                },
1531                ConfiguredFipsPeer {
1532                    npub: "npub1cache".to_string(),
1533                    udp_addresses: Vec::new(),
1534                },
1535            ]
1536        );
1537        assert!(!config.server.enable_fips_udp);
1538        assert_eq!(
1539            config.server.fips_udp_bind_addr.as_deref(),
1540            Some("0.0.0.0:2121")
1541        );
1542        assert!(config.server.fips_udp_public);
1543        assert_eq!(
1544            config.server.fips_udp_external_addr.as_deref(),
1545            Some("198.19.77.10:2121")
1546        );
1547        assert!(config.server.enable_fips_webrtc);
1548        assert_eq!(config.server.fips_ethernet_interfaces, ["eth0"]);
1549        assert!(!config.server.fetch_from_fips_peers);
1550        assert_eq!(config.server.fips_request_timeout_ms, 42);
1551    }
1552
1553    #[test]
1554    fn server_config_accepts_legacy_http_fips_fetch_name() {
1555        let config: Config = toml::from_str(
1556            r#"
1557[server]
1558http_fips_fetch = false
1559"#,
1560        )
1561        .unwrap();
1562
1563        assert!(!config.server.fetch_from_fips_peers);
1564    }
1565
1566    #[test]
1567    fn fips_relay_resolution_prefers_fips_relays_then_nostr() {
1568        let active_nostr = vec!["wss://nostr.example".to_string()];
1569        let mut server = ServerConfig::default();
1570
1571        assert_eq!(
1572            server.resolved_fips_relays(&active_nostr),
1573            [
1574                "wss://nostr.example",
1575                "wss://temp.iris.to",
1576                "wss://relay.primal.net"
1577            ]
1578        );
1579
1580        server.fips_relays = Some(vec!["wss://fips.example".to_string()]);
1581        assert_eq!(
1582            server.resolved_fips_relays(&["wss://ignored.example".to_string()]),
1583            ["wss://fips.example"]
1584        );
1585    }
1586
1587    #[test]
1588    fn explicit_fips_relay_resolution_is_exact_and_normalized() {
1589        let server = ServerConfig {
1590            fips_relays: Some(vec![
1591                "wss://temp.iris.to/".to_string(),
1592                " wss://relay.primal.net ".to_string(),
1593                "wss://temp.iris.to".to_string(),
1594                "wss://extra.example".to_string(),
1595            ]),
1596            ..ServerConfig::default()
1597        };
1598
1599        assert_eq!(
1600            server.resolved_fips_relays(&[]),
1601            [
1602                "wss://temp.iris.to",
1603                "wss://relay.primal.net",
1604                "wss://extra.example"
1605            ]
1606        );
1607    }
1608
1609    #[test]
1610    fn explicit_empty_fips_relays_disable_all_relay_discovery() {
1611        let config: Config = toml::from_str(
1612            r#"
1613[server]
1614enable_fips = true
1615enable_fips_udp = false
1616enable_fips_webrtc = false
1617fips_ethernet_interfaces = ["eth0"]
1618fips_relays = []
1619
1620[nostr]
1621relays = ["wss://must-not-open.example"]
1622event_transport = "fips-local-only"
1623"#,
1624        )
1625        .unwrap();
1626
1627        assert_eq!(config.server.fips_relays, Some(Vec::new()));
1628        assert_eq!(
1629            config.nostr.event_transport,
1630            NostrEventTransport::FipsLocalOnly
1631        );
1632        assert!(config.nostr.active_relays().is_empty());
1633        assert!(config
1634            .server
1635            .resolved_fips_relays(&["wss://must-not-open.example".to_string()])
1636            .is_empty());
1637    }
1638}