Skip to main content

hashtree_cli/
config.rs

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