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, Serialize, Deserialize)]
553pub struct CashuConfig {
554    /// Cashu mint base URLs we accept for bandwidth incentives.
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    /// Default post-delivery payment offer for quoted retrievals.
561    #[serde(default = "default_cashu_quote_payment_offer_sat")]
562    pub quote_payment_offer_sat: u64,
563    /// Quote validity window in milliseconds.
564    #[serde(default = "default_cashu_quote_ttl_ms")]
565    pub quote_ttl_ms: u32,
566    /// Maximum time to wait for post-delivery settlement before recording a default.
567    #[serde(default = "default_cashu_settlement_timeout_ms")]
568    pub settlement_timeout_ms: u64,
569    /// Block mints whose failed redemptions keep outnumbering successful redemptions.
570    #[serde(default = "default_cashu_mint_failure_block_threshold")]
571    pub mint_failure_block_threshold: u64,
572    /// Base cap for trying a peer-suggested mint we do not already trust.
573    #[serde(default = "default_cashu_peer_suggested_mint_base_cap_sat")]
574    pub peer_suggested_mint_base_cap_sat: u64,
575    /// Additional cap granted per successful delivery from that peer.
576    #[serde(default = "default_cashu_peer_suggested_mint_success_step_sat")]
577    pub peer_suggested_mint_success_step_sat: u64,
578    /// Additional cap granted per settled payment received from that peer.
579    #[serde(default = "default_cashu_peer_suggested_mint_receipt_step_sat")]
580    pub peer_suggested_mint_receipt_step_sat: u64,
581    /// Hard ceiling for untrusted peer-suggested mint exposure.
582    #[serde(default = "default_cashu_peer_suggested_mint_max_cap_sat")]
583    pub peer_suggested_mint_max_cap_sat: u64,
584    /// Block serving peers whose unpaid defaults reach this threshold.
585    #[serde(default)]
586    pub payment_default_block_threshold: u64,
587    /// Target chunk size for quoted paid delivery.
588    #[serde(default = "default_cashu_chunk_target_bytes")]
589    pub chunk_target_bytes: usize,
590}
591
592impl Default for CashuConfig {
593    fn default() -> Self {
594        Self {
595            accepted_mints: Vec::new(),
596            default_mint: None,
597            quote_payment_offer_sat: default_cashu_quote_payment_offer_sat(),
598            quote_ttl_ms: default_cashu_quote_ttl_ms(),
599            settlement_timeout_ms: default_cashu_settlement_timeout_ms(),
600            mint_failure_block_threshold: default_cashu_mint_failure_block_threshold(),
601            peer_suggested_mint_base_cap_sat: default_cashu_peer_suggested_mint_base_cap_sat(),
602            peer_suggested_mint_success_step_sat:
603                default_cashu_peer_suggested_mint_success_step_sat(),
604            peer_suggested_mint_receipt_step_sat:
605                default_cashu_peer_suggested_mint_receipt_step_sat(),
606            peer_suggested_mint_max_cap_sat: default_cashu_peer_suggested_mint_max_cap_sat(),
607            payment_default_block_threshold: 0,
608            chunk_target_bytes: default_cashu_chunk_target_bytes(),
609        }
610    }
611}
612
613fn default_cashu_quote_payment_offer_sat() -> u64 {
614    3
615}
616
617fn default_cashu_quote_ttl_ms() -> u32 {
618    1_500
619}
620
621fn default_cashu_settlement_timeout_ms() -> u64 {
622    5_000
623}
624
625fn default_cashu_mint_failure_block_threshold() -> u64 {
626    2
627}
628
629fn default_cashu_peer_suggested_mint_base_cap_sat() -> u64 {
630    3
631}
632
633fn default_cashu_peer_suggested_mint_success_step_sat() -> u64 {
634    1
635}
636
637fn default_cashu_peer_suggested_mint_receipt_step_sat() -> u64 {
638    2
639}
640
641fn default_cashu_peer_suggested_mint_max_cap_sat() -> u64 {
642    21
643}
644
645fn default_cashu_chunk_target_bytes() -> usize {
646    32 * 1024
647}
648
649fn default_sync_enabled() -> bool {
650    true
651}
652
653fn default_sync_own() -> bool {
654    true
655}
656
657fn default_sync_followed() -> bool {
658    true
659}
660
661fn default_max_concurrent() -> usize {
662    3
663}
664
665fn default_blossom_timeout_ms() -> u64 {
666    10000
667}
668
669fn default_social_graph_crawl_depth() -> u32 {
670    2
671}
672
673fn default_nostr_bootstrap_follows() -> Vec<String> {
674    vec![hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB.to_string()]
675}
676
677fn default_max_write_distance() -> u32 {
678    3
679}
680
681fn default_nostr_db_max_size_gb() -> u64 {
682    10
683}
684
685fn default_nostr_spambox_max_size_gb() -> u64 {
686    1
687}
688
689fn default_nostr_history_sync_on_reconnect() -> bool {
690    true
691}
692
693fn default_nostr_overmute_threshold() -> f64 {
694    1.0
695}
696
697fn default_nostr_mirror_kinds() -> Vec<u16> {
698    vec![
699        0, 1, 3, 5, 6, 7, 16, 20, 1_111, 9_735, 10_000, 30_000, 30_023,
700    ]
701}
702
703fn default_nostr_history_sync_author_chunk_size() -> usize {
704    5_000
705}
706
707fn default_nostr_history_sync_per_author_event_limit() -> usize {
708    256
709}
710
711fn default_nostr_full_text_note_history_follow_distance() -> Option<u32> {
712    Some(2)
713}
714
715fn default_nostr_full_text_note_history_max_relay_pages() -> usize {
716    0
717}
718
719fn default_nostr_archive_history_follow_distance() -> Option<u32> {
720    Some(2)
721}
722
723fn default_nostr_archive_history_max_relay_pages() -> usize {
724    0
725}
726
727fn default_relays() -> Vec<String> {
728    vec![
729        "wss://nos.lol".to_string(),
730        "wss://relay.snort.social".to_string(),
731        "wss://temp.iris.to".to_string(),
732    ]
733}
734
735fn default_bind_address() -> String {
736    "127.0.0.1:8080".to_string()
737}
738
739fn default_enable_auth() -> bool {
740    true
741}
742
743fn default_enable_fips() -> bool {
744    true
745}
746
747fn default_enable_fips_lan_discovery() -> bool {
748    true
749}
750
751fn default_fips_discovery_scope() -> String {
752    hashtree_fips_transport::DEFAULT_FIPS_DISCOVERY_SCOPE.to_string()
753}
754
755fn default_enable_fips_udp() -> bool {
756    true
757}
758
759fn default_enable_fips_webrtc() -> bool {
760    cfg!(feature = "fips-webrtc")
761}
762
763fn default_fetch_from_fips_peers() -> bool {
764    true
765}
766
767fn default_fips_request_timeout_ms() -> u64 {
768    5_500
769}
770
771fn default_data_dir() -> String {
772    hashtree_config::get_hashtree_dir()
773        .join("data")
774        .to_string_lossy()
775        .to_string()
776}
777
778fn default_max_size_gb() -> u64 {
779    10
780}
781
782fn default_storage_evict_orphans() -> bool {
783    true
784}
785
786impl Default for ServerConfig {
787    fn default() -> Self {
788        Self {
789            mode: ServerMode::default(),
790            bind_address: default_bind_address(),
791            enable_auth: default_enable_auth(),
792            enable_fips: default_enable_fips(),
793            fips_discovery_scope: default_fips_discovery_scope(),
794            fips_open_discovery_max_pending: 0,
795            fips_local_rendezvous_addr: None,
796            enable_fips_lan_discovery: default_enable_fips_lan_discovery(),
797            fips_relays: None,
798            fips_peers: Vec::new(),
799            enable_fips_udp: default_enable_fips_udp(),
800            fips_udp_bind_addr: None,
801            fips_udp_public: false,
802            fips_udp_external_addr: None,
803            enable_fips_webrtc: default_enable_fips_webrtc(),
804            fips_ethernet_interfaces: Vec::new(),
805            fetch_from_fips_peers: default_fetch_from_fips_peers(),
806            fips_request_timeout_ms: default_fips_request_timeout_ms(),
807            public_writes: default_public_writes(),
808            public_plaintext_reads: default_public_plaintext_reads(),
809            socialgraph_snapshot_public: default_socialgraph_snapshot_public(),
810        }
811    }
812}
813
814impl Default for StorageConfig {
815    fn default() -> Self {
816        Self {
817            data_dir: default_data_dir(),
818            max_size_gb: default_max_size_gb(),
819            evict_orphans: default_storage_evict_orphans(),
820            s3: None,
821        }
822    }
823}
824
825impl Default for NostrConfig {
826    fn default() -> Self {
827        Self {
828            enabled: default_nostr_enabled(),
829            relays: default_relays(),
830            event_transport: NostrEventTransport::default(),
831            allowed_npubs: Vec::new(),
832            socialgraph_root: None,
833            bootstrap_follows: default_nostr_bootstrap_follows(),
834            social_graph_crawl_depth: default_social_graph_crawl_depth(),
835            mirror_max_follow_distance: None,
836            max_write_distance: default_max_write_distance(),
837            db_max_size_gb: default_nostr_db_max_size_gb(),
838            spambox_max_size_gb: default_nostr_spambox_max_size_gb(),
839            negentropy_only: false,
840            overmute_threshold: default_nostr_overmute_threshold(),
841            mirror_kinds: default_nostr_mirror_kinds(),
842            history_sync_author_chunk_size: default_nostr_history_sync_author_chunk_size(),
843            history_sync_per_author_event_limit: default_nostr_history_sync_per_author_event_limit(
844            ),
845            history_sync_on_reconnect: default_nostr_history_sync_on_reconnect(),
846            full_text_note_history_follow_distance:
847                default_nostr_full_text_note_history_follow_distance(),
848            full_text_note_history_max_relay_pages:
849                default_nostr_full_text_note_history_max_relay_pages(),
850            archive_history_follow_distance: default_nostr_archive_history_follow_distance(),
851            archive_history_max_relay_pages: default_nostr_archive_history_max_relay_pages(),
852            decentralized_pubsub: false,
853            decentralized_pubsub_max_event_bytes:
854                default_nostr_decentralized_pubsub_max_event_bytes(),
855        }
856    }
857}
858
859impl Default for BlossomConfig {
860    fn default() -> Self {
861        Self {
862            enabled: default_blossom_enabled(),
863            servers: Vec::new(),
864            read_servers: default_read_servers(),
865            write_servers: default_write_servers(),
866            max_upload_mb: default_max_upload_mb(),
867            require_random_untrusted_ingest: default_require_random_untrusted_ingest(),
868            optimistic_uploads: default_optimistic_uploads(),
869            replicate_servers: Vec::new(),
870            replicate_queue_mb: default_replicate_queue_mb(),
871        }
872    }
873}
874
875impl Default for SyncConfig {
876    fn default() -> Self {
877        Self {
878            enabled: default_sync_enabled(),
879            sync_own: default_sync_own(),
880            sync_followed: default_sync_followed(),
881            max_concurrent: default_max_concurrent(),
882            blossom_timeout_ms: default_blossom_timeout_ms(),
883        }
884    }
885}
886
887impl Config {
888    /// Load config from file, or create default if doesn't exist
889    pub fn load() -> Result<Self> {
890        let config_path = get_config_path();
891
892        if config_path.exists() {
893            let content = fs::read_to_string(&config_path).context("Failed to read config file")?;
894            toml::from_str(&content).context("Failed to parse config file")
895        } else {
896            let config = Config::default();
897            config.save()?;
898            Ok(config)
899        }
900    }
901
902    /// Save config to file
903    pub fn save(&self) -> Result<()> {
904        let config_path = get_config_path();
905
906        // Ensure parent directory exists
907        if let Some(parent) = config_path.parent() {
908            fs::create_dir_all(parent)?;
909        }
910
911        let content = toml::to_string_pretty(self)?;
912        fs::write(&config_path, content)?;
913
914        Ok(())
915    }
916}
917
918// Re-export path functions from hashtree_config
919pub use hashtree_config::{get_auth_cookie_path, get_config_path, get_hashtree_dir, get_keys_path};
920
921fn read_keys_from_path(keys_path: &Path) -> Result<Keys> {
922    let content = fs::read_to_string(keys_path).context("Failed to read keys file")?;
923    let entries = hashtree_config::parse_keys_file(&content);
924    let nsec_str = entries
925        .into_iter()
926        .next()
927        .map(|e| e.secret)
928        .context("Keys file is empty")?;
929    let secret_key = SecretKey::from_bech32(&nsec_str).context("Invalid nsec format")?;
930    Ok(Keys::new(secret_key))
931}
932
933fn seed_identity_defaults_if_needed(data_dir: Option<&Path>, config: Option<&Config>) {
934    if let (Some(data_dir), Some(config)) = (data_dir, config) {
935        let _ = crate::bootstrap::seed_identity_defaults(data_dir, config);
936    }
937}
938
939fn write_keys_to_path(keys_path: &Path, keys: &Keys) -> Result<()> {
940    if let Some(parent) = keys_path.parent() {
941        fs::create_dir_all(parent)?;
942    }
943
944    let nsec = keys
945        .secret_key()
946        .to_bech32()
947        .context("Failed to encode nsec")?;
948    fs::write(keys_path, &nsec)?;
949
950    #[cfg(unix)]
951    {
952        use std::os::unix::fs::PermissionsExt;
953        let perms = fs::Permissions::from_mode(0o600);
954        fs::set_permissions(keys_path, perms)?;
955    }
956
957    Ok(())
958}
959
960/// Generate and save auth cookie if it doesn't exist
961pub fn ensure_auth_cookie() -> Result<(String, String)> {
962    let cookie_path = get_auth_cookie_path();
963
964    if cookie_path.exists() {
965        read_auth_cookie()
966    } else {
967        generate_auth_cookie()
968    }
969}
970
971/// Read existing auth cookie
972pub fn read_auth_cookie() -> Result<(String, String)> {
973    let cookie_path = get_auth_cookie_path();
974    let content = fs::read_to_string(&cookie_path).context("Failed to read auth cookie")?;
975
976    let parts: Vec<&str> = content.trim().split(':').collect();
977    if parts.len() != 2 {
978        anyhow::bail!("Invalid auth cookie format");
979    }
980
981    Ok((parts[0].to_string(), parts[1].to_string()))
982}
983
984/// Ensure keys file exists, generating one if not present
985/// Returns (Keys, was_generated)
986pub fn ensure_keys() -> Result<(Keys, bool)> {
987    let config_dir = get_hashtree_dir();
988    let config = Config::load().ok();
989    let data_dir = config
990        .as_ref()
991        .map(|cfg| Path::new(cfg.storage.data_dir.as_str()));
992    ensure_keys_in(&config_dir, data_dir, config.as_ref())
993}
994
995/// Ensure keys exist inside an explicit config directory.
996/// Returns (Keys, was_generated)
997pub fn ensure_keys_in(
998    config_dir: &Path,
999    data_dir: Option<&Path>,
1000    config: Option<&Config>,
1001) -> Result<(Keys, bool)> {
1002    let keys_path = config_dir.join("keys");
1003
1004    if keys_path.exists() {
1005        Ok((read_keys_from_path(&keys_path)?, false))
1006    } else {
1007        let keys = generate_keys_in(config_dir, data_dir, config)?;
1008        Ok((keys, true))
1009    }
1010}
1011
1012/// Read existing keys
1013pub fn read_keys() -> Result<Keys> {
1014    read_keys_in(&get_hashtree_dir())
1015}
1016
1017/// Read keys from an explicit config directory.
1018pub fn read_keys_in(config_dir: &Path) -> Result<Keys> {
1019    read_keys_from_path(&config_dir.join("keys"))
1020}
1021
1022/// Get nsec string, ensuring keys file exists (generate if needed)
1023/// Returns (nsec_string, was_generated)
1024pub fn ensure_keys_string() -> Result<(String, bool)> {
1025    let config_dir = get_hashtree_dir();
1026    let config = Config::load().ok();
1027    let data_dir = config
1028        .as_ref()
1029        .map(|cfg| Path::new(cfg.storage.data_dir.as_str()));
1030    ensure_keys_string_in(&config_dir, data_dir, config.as_ref())
1031}
1032
1033/// Ensure key material exists inside an explicit config directory.
1034/// Returns (nsec_string, was_generated)
1035pub fn ensure_keys_string_in(
1036    config_dir: &Path,
1037    data_dir: Option<&Path>,
1038    config: Option<&Config>,
1039) -> Result<(String, bool)> {
1040    let keys_path = config_dir.join("keys");
1041
1042    if keys_path.exists() {
1043        let content = fs::read_to_string(&keys_path).context("Failed to read keys file")?;
1044        let entries = hashtree_config::parse_keys_file(&content);
1045        let nsec_str = entries
1046            .into_iter()
1047            .next()
1048            .map(|e| e.secret)
1049            .context("Keys file is empty")?;
1050        Ok((nsec_str, false))
1051    } else {
1052        let keys = generate_keys_in(config_dir, data_dir, config)?;
1053        let nsec = keys
1054            .secret_key()
1055            .to_bech32()
1056            .context("Failed to encode nsec")?;
1057        Ok((nsec, true))
1058    }
1059}
1060
1061/// Generate new keys and save to file
1062pub fn generate_keys() -> Result<Keys> {
1063    let config_dir = get_hashtree_dir();
1064    let config = Config::load().ok();
1065    let data_dir = config
1066        .as_ref()
1067        .map(|cfg| Path::new(cfg.storage.data_dir.as_str()));
1068    generate_keys_in(&config_dir, data_dir, config.as_ref())
1069}
1070
1071/// Generate new keys in an explicit config directory and optionally seed
1072/// identity defaults into a caller-owned data directory.
1073pub fn generate_keys_in(
1074    config_dir: &Path,
1075    data_dir: Option<&Path>,
1076    config: Option<&Config>,
1077) -> Result<Keys> {
1078    let keys = Keys::generate();
1079    write_keys_to_path(&config_dir.join("keys"), &keys)?;
1080    seed_identity_defaults_if_needed(data_dir, config);
1081    Ok(keys)
1082}
1083
1084/// Get 32-byte pubkey bytes from Keys.
1085pub fn pubkey_bytes(keys: &Keys) -> [u8; 32] {
1086    keys.public_key().to_bytes()
1087}
1088
1089/// Parse npub to 32-byte pubkey
1090pub fn parse_npub(npub: &str) -> Result<[u8; 32]> {
1091    use nostr::PublicKey;
1092    let pk = PublicKey::from_bech32(npub).context("Invalid npub format")?;
1093    Ok(pk.to_bytes())
1094}
1095
1096/// Generate new random auth cookie
1097pub fn generate_auth_cookie() -> Result<(String, String)> {
1098    use rand::Rng;
1099
1100    let cookie_path = get_auth_cookie_path();
1101
1102    // Ensure parent directory exists
1103    if let Some(parent) = cookie_path.parent() {
1104        fs::create_dir_all(parent)?;
1105    }
1106
1107    // Generate random credentials
1108    let mut rng = rand::thread_rng();
1109    let username = format!("htree_{}", rng.gen::<u32>());
1110    let password: String = (0..32)
1111        .map(|_| {
1112            let idx = rng.gen_range(0..62);
1113            match idx {
1114                0..=25 => (b'a' + idx) as char,
1115                26..=51 => (b'A' + (idx - 26)) as char,
1116                _ => (b'0' + (idx - 52)) as char,
1117            }
1118        })
1119        .collect();
1120
1121    // Save to file
1122    let content = format!("{}:{}", username, password);
1123    fs::write(&cookie_path, content)?;
1124
1125    // Set permissions to 0600 (owner read/write only)
1126    #[cfg(unix)]
1127    {
1128        use std::os::unix::fs::PermissionsExt;
1129        let perms = fs::Permissions::from_mode(0o600);
1130        fs::set_permissions(&cookie_path, perms)?;
1131    }
1132
1133    Ok((username, password))
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138    use super::*;
1139    use crate::test_support::{test_env_lock, EnvVarGuard};
1140    use tempfile::TempDir;
1141
1142    #[test]
1143    fn test_config_default() {
1144        let config = Config::default();
1145        assert_eq!(config.server.bind_address, "127.0.0.1:8080");
1146        assert!(config.server.enable_auth);
1147        assert!(!config.server.public_plaintext_reads);
1148        assert_eq!(config.storage.max_size_gb, 10);
1149        assert!(config.storage.evict_orphans);
1150        assert!(config.nostr.enabled);
1151        assert!(config
1152            .nostr
1153            .relays
1154            .contains(&"wss://temp.iris.to".to_string()));
1155        assert!(config.blossom.enabled);
1156        assert!(!config.blossom.optimistic_uploads);
1157        assert!(config.blossom.replicate_servers.is_empty());
1158        assert_eq!(config.blossom.replicate_queue_mb, 256);
1159        assert_eq!(config.nostr.social_graph_crawl_depth, 2);
1160        assert_eq!(config.nostr.mirror_max_follow_distance, None);
1161        assert_eq!(config.nostr.max_write_distance, 3);
1162        assert_eq!(config.nostr.db_max_size_gb, 10);
1163        assert_eq!(config.nostr.spambox_max_size_gb, 1);
1164        assert!(!config.nostr.negentropy_only);
1165        assert_eq!(config.nostr.overmute_threshold, 1.0);
1166        assert_eq!(
1167            config.nostr.mirror_kinds,
1168            vec![0, 1, 3, 5, 6, 7, 16, 20, 1_111, 9_735, 10_000, 30_000, 30_023]
1169        );
1170        assert_eq!(config.nostr.history_sync_author_chunk_size, 5_000);
1171        assert_eq!(config.nostr.history_sync_per_author_event_limit, 256);
1172        assert!(config.nostr.history_sync_on_reconnect);
1173        assert_eq!(config.nostr.full_text_note_history_follow_distance, Some(2));
1174        assert_eq!(config.nostr.full_text_note_history_max_relay_pages, 0);
1175        assert_eq!(config.nostr.archive_history_follow_distance, Some(2));
1176        assert_eq!(config.nostr.archive_history_max_relay_pages, 0);
1177        assert!(config.nostr.socialgraph_root.is_none());
1178        assert_eq!(
1179            config.nostr.bootstrap_follows,
1180            vec![hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB.to_string()]
1181        );
1182        assert!(!config.server.socialgraph_snapshot_public);
1183        assert!(config.cashu.accepted_mints.is_empty());
1184        assert!(config.cashu.default_mint.is_none());
1185        assert_eq!(config.cashu.quote_payment_offer_sat, 3);
1186        assert_eq!(config.cashu.quote_ttl_ms, 1_500);
1187        assert_eq!(config.cashu.settlement_timeout_ms, 5_000);
1188        assert_eq!(config.cashu.mint_failure_block_threshold, 2);
1189        assert_eq!(config.cashu.peer_suggested_mint_base_cap_sat, 3);
1190        assert_eq!(config.cashu.peer_suggested_mint_success_step_sat, 1);
1191        assert_eq!(config.cashu.peer_suggested_mint_receipt_step_sat, 2);
1192        assert_eq!(config.cashu.peer_suggested_mint_max_cap_sat, 21);
1193        assert_eq!(config.cashu.payment_default_block_threshold, 0);
1194        assert_eq!(config.cashu.chunk_target_bytes, 32 * 1024);
1195    }
1196
1197    #[test]
1198    fn test_blossom_optimistic_uploads_deserialize() {
1199        let toml_str = r#"
1200[blossom]
1201optimistic_uploads = true
1202"#;
1203        let config: Config = toml::from_str(toml_str).unwrap();
1204        assert!(config.blossom.optimistic_uploads);
1205        assert!(config.blossom.require_random_untrusted_ingest);
1206    }
1207
1208    #[test]
1209    fn test_blossom_replication_deserialize() {
1210        let toml_str = r#"
1211[blossom]
1212replicate_servers = ["http://127.0.0.1:8081"]
1213replicate_queue_mb = 128
1214"#;
1215        let config: Config = toml::from_str(toml_str).unwrap();
1216        assert_eq!(config.blossom.replicate_servers, ["http://127.0.0.1:8081"]);
1217        assert_eq!(config.blossom.replicate_queue_mb, 128);
1218    }
1219
1220    #[test]
1221    fn test_server_public_plaintext_reads_deserialize() {
1222        let toml_str = r#"
1223[server]
1224public_plaintext_reads = false
1225"#;
1226        let config: Config = toml::from_str(toml_str).unwrap();
1227        assert!(!config.server.public_plaintext_reads);
1228        assert!(config.server.public_writes);
1229    }
1230
1231    #[test]
1232    fn test_nostr_config_deserialize_with_defaults() {
1233        let toml_str = r#"
1234[nostr]
1235relays = ["wss://relay.damus.io"]
1236"#;
1237        let config: Config = toml::from_str(toml_str).unwrap();
1238        assert!(config.nostr.enabled);
1239        assert_eq!(config.nostr.relays, vec!["wss://relay.damus.io"]);
1240        assert!(config.storage.evict_orphans);
1241        assert_eq!(config.nostr.social_graph_crawl_depth, 2);
1242        assert_eq!(config.nostr.mirror_max_follow_distance, None);
1243        assert_eq!(config.nostr.max_write_distance, 3);
1244        assert_eq!(config.nostr.db_max_size_gb, 10);
1245        assert_eq!(config.nostr.spambox_max_size_gb, 1);
1246        assert!(!config.nostr.negentropy_only);
1247        assert_eq!(config.nostr.overmute_threshold, 1.0);
1248        assert_eq!(
1249            config.nostr.mirror_kinds,
1250            vec![0, 1, 3, 5, 6, 7, 16, 20, 1_111, 9_735, 10_000, 30_000, 30_023]
1251        );
1252        assert_eq!(config.nostr.history_sync_author_chunk_size, 5_000);
1253        assert_eq!(config.nostr.history_sync_per_author_event_limit, 256);
1254        assert!(config.nostr.history_sync_on_reconnect);
1255        assert_eq!(config.nostr.full_text_note_history_follow_distance, Some(2));
1256        assert_eq!(config.nostr.full_text_note_history_max_relay_pages, 0);
1257        assert_eq!(config.nostr.archive_history_follow_distance, Some(2));
1258        assert_eq!(config.nostr.archive_history_max_relay_pages, 0);
1259        assert!(config.nostr.socialgraph_root.is_none());
1260        assert_eq!(
1261            config.nostr.bootstrap_follows,
1262            vec![hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB.to_string()]
1263        );
1264    }
1265
1266    #[test]
1267    fn test_nostr_config_deserialize_with_socialgraph() {
1268        let toml_str = r#"
1269[nostr]
1270relays = ["wss://relay.damus.io"]
1271socialgraph_root = "npub1test"
1272bootstrap_follows = []
1273social_graph_crawl_depth = 3
1274mirror_max_follow_distance = 2
1275max_write_distance = 5
1276negentropy_only = true
1277overmute_threshold = 2.5
1278mirror_kinds = [0, 10000]
1279history_sync_author_chunk_size = 250
1280history_sync_per_author_event_limit = 128
1281history_sync_on_reconnect = false
1282full_text_note_history_follow_distance = 1
1283full_text_note_history_max_relay_pages = 64
1284archive_history_follow_distance = 2
1285archive_history_max_relay_pages = 32
1286"#;
1287        let config: Config = toml::from_str(toml_str).unwrap();
1288        assert!(config.nostr.enabled);
1289        assert!(config.storage.evict_orphans);
1290        assert_eq!(config.nostr.socialgraph_root, Some("npub1test".to_string()));
1291        assert!(config.nostr.bootstrap_follows.is_empty());
1292        assert_eq!(config.nostr.social_graph_crawl_depth, 3);
1293        assert_eq!(config.nostr.mirror_max_follow_distance, Some(2));
1294        assert_eq!(config.nostr.max_write_distance, 5);
1295        assert_eq!(config.nostr.db_max_size_gb, 10);
1296        assert_eq!(config.nostr.spambox_max_size_gb, 1);
1297        assert!(config.nostr.negentropy_only);
1298        assert_eq!(config.nostr.overmute_threshold, 2.5);
1299        assert_eq!(config.nostr.mirror_kinds, vec![0, 10_000]);
1300        assert_eq!(config.nostr.history_sync_author_chunk_size, 250);
1301        assert_eq!(config.nostr.history_sync_per_author_event_limit, 128);
1302        assert!(!config.nostr.history_sync_on_reconnect);
1303        assert_eq!(config.nostr.full_text_note_history_follow_distance, Some(1));
1304        assert_eq!(config.nostr.full_text_note_history_max_relay_pages, 64);
1305        assert_eq!(config.nostr.archive_history_follow_distance, Some(2));
1306        assert_eq!(config.nostr.archive_history_max_relay_pages, 32);
1307    }
1308
1309    #[test]
1310    fn test_nostr_config_deserialize_legacy_crawl_depth_alias() {
1311        let toml_str = r#"
1312[nostr]
1313relays = ["wss://relay.damus.io"]
1314crawl_depth = 4
1315"#;
1316        let config: Config = toml::from_str(toml_str).unwrap();
1317        assert_eq!(config.nostr.social_graph_crawl_depth, 4);
1318    }
1319
1320    #[test]
1321    fn test_storage_config_disables_orphan_eviction_when_requested() {
1322        let toml_str = r#"
1323[storage]
1324evict_orphans = false
1325"#;
1326        let config: Config = toml::from_str(toml_str).unwrap();
1327        assert!(!config.storage.evict_orphans);
1328    }
1329
1330    #[test]
1331    fn test_cashu_config_deserialize_with_accepted_mints() {
1332        let toml_str = r#"
1333[cashu]
1334accepted_mints = ["https://mint1.example", "http://127.0.0.1:3338"]
1335default_mint = "https://mint1.example"
1336quote_payment_offer_sat = 5
1337quote_ttl_ms = 2500
1338settlement_timeout_ms = 7000
1339mint_failure_block_threshold = 3
1340peer_suggested_mint_base_cap_sat = 4
1341peer_suggested_mint_success_step_sat = 2
1342peer_suggested_mint_receipt_step_sat = 3
1343peer_suggested_mint_max_cap_sat = 34
1344payment_default_block_threshold = 2
1345chunk_target_bytes = 65536
1346"#;
1347        let config: Config = toml::from_str(toml_str).unwrap();
1348        assert_eq!(
1349            config.cashu.accepted_mints,
1350            vec![
1351                "https://mint1.example".to_string(),
1352                "http://127.0.0.1:3338".to_string()
1353            ]
1354        );
1355        assert_eq!(
1356            config.cashu.default_mint,
1357            Some("https://mint1.example".to_string())
1358        );
1359        assert_eq!(config.cashu.quote_payment_offer_sat, 5);
1360        assert_eq!(config.cashu.quote_ttl_ms, 2500);
1361        assert_eq!(config.cashu.settlement_timeout_ms, 7_000);
1362        assert_eq!(config.cashu.mint_failure_block_threshold, 3);
1363        assert_eq!(config.cashu.peer_suggested_mint_base_cap_sat, 4);
1364        assert_eq!(config.cashu.peer_suggested_mint_success_step_sat, 2);
1365        assert_eq!(config.cashu.peer_suggested_mint_receipt_step_sat, 3);
1366        assert_eq!(config.cashu.peer_suggested_mint_max_cap_sat, 34);
1367        assert_eq!(config.cashu.payment_default_block_threshold, 2);
1368        assert_eq!(config.cashu.chunk_target_bytes, 65_536);
1369    }
1370
1371    #[test]
1372    fn test_auth_cookie_generation() -> Result<()> {
1373        let _lock = test_env_lock().blocking_lock();
1374        let temp_dir = TempDir::new()?;
1375        let _guard = EnvVarGuard::set("HTREE_CONFIG_DIR", temp_dir.path());
1376
1377        let (username, password) = generate_auth_cookie()?;
1378
1379        assert!(username.starts_with("htree_"));
1380        assert_eq!(password.len(), 32);
1381
1382        // Verify cookie file exists
1383        let cookie_path = get_auth_cookie_path();
1384        assert!(cookie_path.exists());
1385
1386        // Verify reading works
1387        let (u2, p2) = read_auth_cookie()?;
1388        assert_eq!(username, u2);
1389        assert_eq!(password, p2);
1390
1391        Ok(())
1392    }
1393
1394    #[test]
1395    fn test_blossom_read_servers_include_write_only_servers_as_fresh_fallbacks() {
1396        let config = BlossomConfig {
1397            servers: vec!["https://legacy.server".to_string()],
1398            ..BlossomConfig::default()
1399        };
1400
1401        let read = config.all_read_servers();
1402        assert!(read.contains(&"https://legacy.server".to_string()));
1403        assert!(read.contains(&"https://cdn.iris.to".to_string()));
1404        assert!(read.contains(&"https://blossom.primal.net".to_string()));
1405        assert!(read.contains(&"https://upload.iris.to".to_string()));
1406
1407        let write = config.all_write_servers();
1408        assert!(write.contains(&"https://legacy.server".to_string()));
1409        assert!(write.contains(&"https://upload.iris.to".to_string()));
1410    }
1411
1412    #[test]
1413    fn daemon_blossom_upstreams_exclude_its_own_loopback_http_endpoint() {
1414        let config = BlossomConfig {
1415            servers: Vec::new(),
1416            read_servers: vec![
1417                "http://127.0.0.1:19092".to_string(),
1418                "http://localhost:19092/".to_string(),
1419                "http://127.0.0.1:19093".to_string(),
1420                "https://127.0.0.1:19092".to_string(),
1421                "https://read.example".to_string(),
1422            ],
1423            write_servers: Vec::new(),
1424            ..BlossomConfig::default()
1425        };
1426
1427        let upstreams = config.upstream_read_servers("127.0.0.1:19092");
1428
1429        assert!(!upstreams
1430            .iter()
1431            .any(|server| server == "http://127.0.0.1:19092"));
1432        assert!(!upstreams
1433            .iter()
1434            .any(|server| server == "http://localhost:19092/"));
1435        assert!(upstreams
1436            .iter()
1437            .any(|server| server == "http://127.0.0.1:19093"));
1438        assert!(upstreams
1439            .iter()
1440            .any(|server| server == "https://127.0.0.1:19092"));
1441        assert!(upstreams
1442            .iter()
1443            .any(|server| server == "https://read.example"));
1444    }
1445
1446    #[test]
1447    fn wildcard_daemon_bind_excludes_loopback_self_but_keeps_remote_upstreams() {
1448        let config = BlossomConfig {
1449            servers: Vec::new(),
1450            read_servers: vec![
1451                "http://localhost:8080".to_string(),
1452                "http://[::1]:8080".to_string(),
1453                "http://192.0.2.10:8080".to_string(),
1454            ],
1455            write_servers: Vec::new(),
1456            ..BlossomConfig::default()
1457        };
1458
1459        let upstreams = config.upstream_read_servers("0.0.0.0:8080");
1460
1461        assert!(!upstreams
1462            .iter()
1463            .any(|server| server == "http://localhost:8080"));
1464        assert!(!upstreams.iter().any(|server| server == "http://[::1]:8080"));
1465        assert!(upstreams
1466            .iter()
1467            .any(|server| server == "http://192.0.2.10:8080"));
1468    }
1469
1470    #[test]
1471    fn test_blossom_servers_fall_back_to_defaults_when_explicitly_empty() {
1472        let config = BlossomConfig {
1473            enabled: true,
1474            servers: Vec::new(),
1475            read_servers: Vec::new(),
1476            write_servers: Vec::new(),
1477            max_upload_mb: default_max_upload_mb(),
1478            require_random_untrusted_ingest: default_require_random_untrusted_ingest(),
1479            optimistic_uploads: default_optimistic_uploads(),
1480            replicate_servers: Vec::new(),
1481            replicate_queue_mb: default_replicate_queue_mb(),
1482        };
1483
1484        let read = config.all_read_servers();
1485        let mut expected = default_read_servers();
1486        expected.extend(default_write_servers());
1487        expected.sort();
1488        expected.dedup();
1489        assert_eq!(read, expected);
1490
1491        let write = config.all_write_servers();
1492        assert_eq!(write, default_write_servers());
1493    }
1494
1495    #[test]
1496    fn test_disabled_sources_preserve_lists_but_return_no_active_endpoints() {
1497        let nostr = NostrConfig {
1498            enabled: false,
1499            relays: vec!["wss://relay.example".to_string()],
1500            ..NostrConfig::default()
1501        };
1502        assert!(nostr.active_relays().is_empty());
1503
1504        let blossom = BlossomConfig {
1505            enabled: false,
1506            servers: vec!["https://legacy.server".to_string()],
1507            read_servers: vec!["https://read.example".to_string()],
1508            write_servers: vec!["https://write.example".to_string()],
1509            max_upload_mb: default_max_upload_mb(),
1510            require_random_untrusted_ingest: default_require_random_untrusted_ingest(),
1511            optimistic_uploads: default_optimistic_uploads(),
1512            replicate_servers: Vec::new(),
1513            replicate_queue_mb: default_replicate_queue_mb(),
1514        };
1515        assert!(blossom.all_read_servers().is_empty());
1516        assert!(blossom.all_write_servers().is_empty());
1517    }
1518
1519    #[test]
1520    fn fips_local_only_event_transport_never_exposes_direct_relays() {
1521        let nostr = NostrConfig {
1522            relays: vec!["wss://must-not-open.example".to_string()],
1523            event_transport: NostrEventTransport::FipsLocalOnly,
1524            ..NostrConfig::default()
1525        };
1526
1527        assert!(nostr.active_relays().is_empty());
1528    }
1529
1530    #[test]
1531    fn nostr_decentralized_pubsub_requires_config_and_feature() {
1532        let default_nostr = NostrConfig::default();
1533        assert!(!default_nostr.decentralized_pubsub);
1534        assert!(!default_nostr.decentralized_pubsub_enabled());
1535
1536        let enabled: NostrConfig = toml::from_str("decentralized_pubsub = true")
1537            .expect("parse decentralized pubsub nostr config");
1538        assert!(enabled.decentralized_pubsub);
1539        assert_eq!(
1540            enabled.decentralized_pubsub_max_event_bytes,
1541            nostr_pubsub_fips::FIPS_NOSTR_PUBSUB_MAX_DATAGRAM_BYTES
1542        );
1543        assert_eq!(
1544            enabled.decentralized_pubsub_enabled(),
1545            cfg!(feature = "experimental-decentralized-pubsub")
1546        );
1547
1548        let disabled: NostrConfig = toml::from_str(
1549            r#"
1550enabled = false
1551decentralized_pubsub = true
1552"#,
1553        )
1554        .expect("parse disabled decentralized pubsub nostr config");
1555        assert!(!disabled.decentralized_pubsub_enabled());
1556
1557        let alias: NostrConfig =
1558            toml::from_str("relayless_pubsub = true").expect("parse compatibility pubsub alias");
1559        assert!(alias.decentralized_pubsub);
1560
1561        let tuned: NostrConfig = toml::from_str(
1562            r#"
1563decentralized_pubsub = true
1564decentralized_pubsub_max_event_bytes = 4096
1565"#,
1566        )
1567        .expect("parse tuned decentralized pubsub config");
1568        assert_eq!(tuned.decentralized_pubsub_max_event_bytes, 4096);
1569    }
1570
1571    #[test]
1572    fn server_defaults_enable_fips_udp_and_feature_gated_webrtc() {
1573        let server = ServerConfig::default();
1574
1575        assert!(server.enable_fips);
1576        assert!(server.enable_fips_udp);
1577        assert!(server.fips_udp_bind_addr.is_none());
1578        assert!(!server.fips_udp_public);
1579        assert!(server.fips_udp_external_addr.is_none());
1580        assert_eq!(server.enable_fips_webrtc, cfg!(feature = "fips-webrtc"));
1581        assert!(server.fips_ethernet_interfaces.is_empty());
1582        assert!(server.fetch_from_fips_peers);
1583        assert!(server.fips_relays.is_none());
1584        assert!(server.fips_peers.is_empty());
1585        assert_eq!(server.fips_discovery_scope, "fips-overlay-v1");
1586        assert_eq!(server.fips_open_discovery_max_pending, 0);
1587        assert!(server.fips_local_rendezvous_addr.is_none());
1588        assert!(server.enable_fips_lan_discovery);
1589        assert_eq!(server.fips_request_timeout_ms, 5_500);
1590    }
1591
1592    #[test]
1593    fn server_config_reads_fips_overrides() {
1594        let config: Config = toml::from_str(
1595            r#"
1596[server]
1597enable_fips = true
1598fips_discovery_scope = "test-hashtree"
1599fips_open_discovery_max_pending = 32
1600fips_local_rendezvous_addr = "127.0.0.1:32111"
1601enable_fips_lan_discovery = false
1602fips_relays = ["wss://fips.example"]
1603fips_peers = [
1604  { npub = "npub1origin", udp_addresses = ["udp:192.0.2.10:2121"] },
1605  { npub = "npub1cache" },
1606]
1607enable_fips_udp = false
1608fips_udp_bind_addr = "0.0.0.0:2121"
1609fips_udp_public = true
1610fips_udp_external_addr = "198.19.77.10:2121"
1611enable_fips_webrtc = true
1612fips_ethernet_interfaces = ["eth0"]
1613fetch_from_fips_peers = false
1614fips_request_timeout_ms = 42
1615"#,
1616        )
1617        .unwrap();
1618
1619        assert!(config.server.enable_fips);
1620        assert_eq!(config.server.fips_discovery_scope, "test-hashtree");
1621        assert_eq!(config.server.fips_open_discovery_max_pending, 32);
1622        assert_eq!(
1623            config.server.fips_local_rendezvous_addr.as_deref(),
1624            Some("127.0.0.1:32111")
1625        );
1626        assert!(!config.server.enable_fips_lan_discovery);
1627        assert_eq!(
1628            config.server.fips_relays,
1629            Some(vec!["wss://fips.example".to_string()])
1630        );
1631        assert_eq!(
1632            config.server.fips_peers,
1633            [
1634                ConfiguredFipsPeer {
1635                    npub: "npub1origin".to_string(),
1636                    udp_addresses: vec!["udp:192.0.2.10:2121".to_string()],
1637                },
1638                ConfiguredFipsPeer {
1639                    npub: "npub1cache".to_string(),
1640                    udp_addresses: Vec::new(),
1641                },
1642            ]
1643        );
1644        assert!(!config.server.enable_fips_udp);
1645        assert_eq!(
1646            config.server.fips_udp_bind_addr.as_deref(),
1647            Some("0.0.0.0:2121")
1648        );
1649        assert!(config.server.fips_udp_public);
1650        assert_eq!(
1651            config.server.fips_udp_external_addr.as_deref(),
1652            Some("198.19.77.10:2121")
1653        );
1654        assert!(config.server.enable_fips_webrtc);
1655        assert_eq!(config.server.fips_ethernet_interfaces, ["eth0"]);
1656        assert!(!config.server.fetch_from_fips_peers);
1657        assert_eq!(config.server.fips_request_timeout_ms, 42);
1658    }
1659
1660    #[test]
1661    fn server_config_accepts_legacy_http_fips_fetch_name() {
1662        let config: Config = toml::from_str(
1663            r#"
1664[server]
1665http_fips_fetch = false
1666"#,
1667        )
1668        .unwrap();
1669
1670        assert!(!config.server.fetch_from_fips_peers);
1671    }
1672
1673    #[test]
1674    fn fips_relay_resolution_prefers_fips_relays_then_nostr() {
1675        let active_nostr = vec!["wss://nostr.example".to_string()];
1676        let mut server = ServerConfig::default();
1677
1678        assert_eq!(
1679            server.resolved_fips_relays(&active_nostr),
1680            [
1681                "wss://nostr.example",
1682                "wss://temp.iris.to",
1683                "wss://relay.primal.net"
1684            ]
1685        );
1686
1687        server.fips_relays = Some(vec!["wss://fips.example".to_string()]);
1688        assert_eq!(
1689            server.resolved_fips_relays(&["wss://ignored.example".to_string()]),
1690            ["wss://fips.example"]
1691        );
1692    }
1693
1694    #[test]
1695    fn explicit_fips_relay_resolution_is_exact_and_normalized() {
1696        let server = ServerConfig {
1697            fips_relays: Some(vec![
1698                "wss://temp.iris.to/".to_string(),
1699                " wss://relay.primal.net ".to_string(),
1700                "wss://temp.iris.to".to_string(),
1701                "wss://extra.example".to_string(),
1702            ]),
1703            ..ServerConfig::default()
1704        };
1705
1706        assert_eq!(
1707            server.resolved_fips_relays(&[]),
1708            [
1709                "wss://temp.iris.to",
1710                "wss://relay.primal.net",
1711                "wss://extra.example"
1712            ]
1713        );
1714    }
1715
1716    #[test]
1717    fn explicit_empty_fips_relays_disable_all_relay_discovery() {
1718        let config: Config = toml::from_str(
1719            r#"
1720[server]
1721enable_fips = true
1722enable_fips_udp = false
1723enable_fips_webrtc = false
1724fips_ethernet_interfaces = ["eth0"]
1725fips_relays = []
1726
1727[nostr]
1728relays = ["wss://must-not-open.example"]
1729event_transport = "fips-local-only"
1730"#,
1731        )
1732        .unwrap();
1733
1734        assert_eq!(config.server.fips_relays, Some(Vec::new()));
1735        assert_eq!(
1736            config.nostr.event_transport,
1737            NostrEventTransport::FipsLocalOnly
1738        );
1739        assert!(config.nostr.active_relays().is_empty());
1740        assert!(config
1741            .server
1742            .resolved_fips_relays(&["wss://must-not-open.example".to_string()])
1743            .is_empty());
1744    }
1745}