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