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