Skip to main content

hashtree_embedded/
lib.rs

1use anyhow::{Context, Result};
2use hashtree_cli::daemon::{EmbeddedDaemonInfo, EmbeddedDaemonOptions};
3use hashtree_cli::Config;
4use hashtree_core::Cid;
5use serde::Deserialize;
6use std::path::{Path, PathBuf};
7
8const BROWSER_STORAGE_MAX_SIZE_GB: u64 = 1;
9const BROWSER_SOCIAL_GRAPH_DB_MAX_SIZE_GB: u64 = 1;
10const BROWSER_SPAMBOX_DB_MAX_SIZE_GB: u64 = 0;
11
12#[derive(Debug, Clone)]
13pub struct HostDaemonOptions {
14    pub state_root: PathBuf,
15    pub bind_address: String,
16    pub initial_tree_roots: Vec<(String, Cid)>,
17}
18
19impl HostDaemonOptions {
20    pub fn new(state_root: impl Into<PathBuf>) -> Self {
21        Self {
22            state_root: state_root.into(),
23            bind_address: "127.0.0.1:0".to_string(),
24            initial_tree_roots: Vec::new(),
25        }
26    }
27
28    pub fn with_initial_tree_roots(mut self, roots: Vec<(String, Cid)>) -> Self {
29        self.initial_tree_roots = roots;
30        self
31    }
32}
33
34#[derive(Debug, Clone)]
35pub struct HostDaemonStatus {
36    pub base_url: String,
37    pub self_npub: String,
38    pub config_dir: PathBuf,
39    pub data_dir: PathBuf,
40}
41
42pub struct HostDaemonRuntime {
43    runtime: tokio::runtime::Runtime,
44    info: Option<EmbeddedDaemonInfo>,
45    bind_address: String,
46    initial_tree_roots: Vec<(String, Cid)>,
47    config_dir: PathBuf,
48    data_dir: PathBuf,
49}
50
51#[derive(Debug, Clone, Default, Deserialize)]
52#[serde(default)]
53#[serde(rename_all = "camelCase")]
54struct BrowserSettings {
55    storage_max_size_gb: Option<u64>,
56    social_graph_db_max_size_gb: Option<u64>,
57    spambox_db_max_size_gb: Option<u64>,
58    nostr_relays: Option<Vec<String>>,
59    blossom_read_servers: Option<Vec<String>>,
60    blossom_write_servers: Option<Vec<String>>,
61    enable_fips: Option<bool>,
62    enable_fips_udp: Option<bool>,
63    enable_fips_webrtc: Option<bool>,
64    fetch_from_fips_peers: Option<bool>,
65    social_graph_crawl_depth: Option<u32>,
66    sync_enabled: Option<bool>,
67    sync_own: Option<bool>,
68    sync_followed: Option<bool>,
69    sync_max_concurrent: Option<usize>,
70    public_writes: Option<bool>,
71    allowed_npubs: Option<Vec<String>>,
72    socialgraph_root: Option<String>,
73}
74
75impl HostDaemonRuntime {
76    pub fn start(options: HostDaemonOptions) -> Result<Self> {
77        let runtime = tokio::runtime::Builder::new_multi_thread()
78            .enable_all()
79            .build()
80            .context("build embedded host runtime")?;
81
82        let config_dir = options.state_root.join("config");
83        let data_dir = options.state_root.join("data");
84        std::fs::create_dir_all(&config_dir).context("create embedded config dir")?;
85        std::fs::create_dir_all(&data_dir).context("create embedded data dir")?;
86
87        let config = browser_config(&data_dir, &config_dir);
88        let info = runtime
89            .block_on(hashtree_cli::daemon::start_embedded(
90                EmbeddedDaemonOptions {
91                    config,
92                    data_dir: data_dir.clone(),
93                    config_dir: Some(config_dir.clone()),
94                    bind_address: options.bind_address,
95                    relays: None,
96                    initial_tree_roots: options.initial_tree_roots.clone(),
97                    extra_routes: None,
98                    cors: None,
99                },
100            ))
101            .context("start embedded hashtree daemon")?;
102
103        let bind_address = info.addr.clone();
104
105        Ok(Self {
106            runtime,
107            info: Some(info),
108            bind_address,
109            initial_tree_roots: options.initial_tree_roots,
110            config_dir,
111            data_dir,
112        })
113    }
114
115    pub fn status(&self) -> HostDaemonStatus {
116        let info = self
117            .info
118            .as_ref()
119            .expect("host daemon status requested after shutdown");
120        HostDaemonStatus {
121            base_url: format!("http://{}", info.addr),
122            self_npub: info.npub.clone(),
123            config_dir: self.config_dir.clone(),
124            data_dir: self.data_dir.clone(),
125        }
126    }
127
128    pub fn base_url(&self) -> String {
129        self.status().base_url
130    }
131
132    pub fn self_npub(&self) -> &str {
133        &self
134            .info
135            .as_ref()
136            .expect("host daemon identity requested after shutdown")
137            .npub
138    }
139
140    pub fn reload(&mut self) -> Result<HostDaemonStatus> {
141        self.shutdown_current();
142
143        let config = browser_config(&self.data_dir, &self.config_dir);
144        let info = self
145            .runtime
146            .block_on(hashtree_cli::daemon::start_embedded(
147                EmbeddedDaemonOptions {
148                    config,
149                    data_dir: self.data_dir.clone(),
150                    config_dir: Some(self.config_dir.clone()),
151                    bind_address: self.bind_address.clone(),
152                    relays: None,
153                    initial_tree_roots: self.initial_tree_roots.clone(),
154                    extra_routes: None,
155                    cors: None,
156                },
157            ))
158            .context("reload embedded hashtree daemon")?;
159        self.bind_address = info.addr.clone();
160        self.info = Some(info);
161        Ok(self.status())
162    }
163
164    pub fn shutdown(&mut self) {
165        self.shutdown_current();
166    }
167
168    fn shutdown_current(&mut self) {
169        let Some(info) = self.info.take() else {
170            return;
171        };
172        let controller = info.daemon_controller.clone();
173        self.runtime.block_on(async move {
174            controller.shutdown().await;
175            drop(info);
176            tokio::task::yield_now().await;
177        });
178    }
179}
180
181impl Drop for HostDaemonRuntime {
182    fn drop(&mut self) {
183        self.shutdown();
184    }
185}
186
187fn browser_config(data_dir: &Path, config_dir: &Path) -> Config {
188    let mut config = Config::default();
189    let settings = load_browser_settings(config_dir).unwrap_or_else(default_browser_settings);
190
191    config.storage.max_size_gb = settings
192        .storage_max_size_gb
193        .unwrap_or(BROWSER_STORAGE_MAX_SIZE_GB)
194        .max(1);
195    config.nostr.db_max_size_gb = settings
196        .social_graph_db_max_size_gb
197        .unwrap_or(BROWSER_SOCIAL_GRAPH_DB_MAX_SIZE_GB)
198        .max(1);
199    config.nostr.spambox_max_size_gb = settings
200        .spambox_db_max_size_gb
201        .unwrap_or(BROWSER_SPAMBOX_DB_MAX_SIZE_GB);
202
203    if let Some(nostr_relays) = settings.nostr_relays {
204        config.nostr.relays = normalize_server_list(nostr_relays);
205        config.nostr.enabled = !config.nostr.relays.is_empty();
206    }
207
208    if let Some(blossom_read_servers) = settings.blossom_read_servers {
209        config.blossom.read_servers = normalize_server_list(blossom_read_servers);
210        config.blossom.servers.clear();
211    }
212    if let Some(blossom_write_servers) = settings.blossom_write_servers {
213        config.blossom.write_servers = normalize_server_list(blossom_write_servers);
214        config.blossom.servers.clear();
215    }
216    config.blossom.enabled =
217        !config.blossom.read_servers.is_empty() || !config.blossom.write_servers.is_empty();
218
219    if let Some(enable_fips) = settings.enable_fips {
220        config.server.enable_fips = enable_fips;
221    }
222    if let Some(enable_fips_udp) = settings.enable_fips_udp {
223        config.server.enable_fips_udp = enable_fips_udp;
224    }
225    if let Some(enable_fips_webrtc) = settings.enable_fips_webrtc {
226        config.server.enable_fips_webrtc = enable_fips_webrtc;
227    }
228    if let Some(fetch_from_fips_peers) = settings.fetch_from_fips_peers {
229        config.server.fetch_from_fips_peers = fetch_from_fips_peers;
230    }
231    if let Some(social_graph_crawl_depth) = settings.social_graph_crawl_depth {
232        config.nostr.social_graph_crawl_depth = social_graph_crawl_depth;
233    }
234
235    config.sync.enabled = settings.sync_enabled.unwrap_or(false);
236    if let Some(sync_own) = settings.sync_own {
237        config.sync.sync_own = sync_own;
238    }
239    if let Some(sync_followed) = settings.sync_followed {
240        config.sync.sync_followed = sync_followed;
241    }
242    if let Some(sync_max_concurrent) = settings.sync_max_concurrent {
243        config.sync.max_concurrent = sync_max_concurrent.max(1);
244    }
245
246    config.server.public_writes = settings.public_writes.unwrap_or(false);
247    if let Some(allowed_npubs) = settings.allowed_npubs {
248        config.nostr.allowed_npubs = normalize_server_list(allowed_npubs);
249    }
250    if let Some(socialgraph_root) = settings.socialgraph_root {
251        let socialgraph_root = socialgraph_root.trim().to_string();
252        config.nostr.socialgraph_root = if socialgraph_root.is_empty() {
253            None
254        } else {
255            Some(socialgraph_root)
256        };
257    }
258    config.storage.data_dir = data_dir.to_string_lossy().to_string();
259    config.server.enable_auth = false;
260    config
261}
262
263fn load_browser_settings(config_dir: &Path) -> Option<BrowserSettings> {
264    let settings_path = config_dir.join("browser_settings.json");
265    let raw = std::fs::read_to_string(settings_path).ok()?;
266    serde_json::from_str(&raw).ok()
267}
268
269fn default_browser_settings() -> BrowserSettings {
270    BrowserSettings {
271        storage_max_size_gb: Some(BROWSER_STORAGE_MAX_SIZE_GB),
272        social_graph_db_max_size_gb: Some(BROWSER_SOCIAL_GRAPH_DB_MAX_SIZE_GB),
273        spambox_db_max_size_gb: Some(BROWSER_SPAMBOX_DB_MAX_SIZE_GB),
274        nostr_relays: None,
275        blossom_read_servers: None,
276        blossom_write_servers: None,
277        enable_fips: None,
278        enable_fips_udp: None,
279        enable_fips_webrtc: None,
280        fetch_from_fips_peers: None,
281        social_graph_crawl_depth: None,
282        sync_enabled: Some(false),
283        sync_own: Some(true),
284        sync_followed: Some(true),
285        sync_max_concurrent: Some(3),
286        public_writes: Some(false),
287        allowed_npubs: None,
288        socialgraph_root: None,
289    }
290}
291
292fn normalize_server_list(values: Vec<String>) -> Vec<String> {
293    let mut normalized = values
294        .into_iter()
295        .map(|value| value.trim().to_string())
296        .filter(|value| !value.is_empty())
297        .collect::<Vec<_>>();
298    normalized.sort();
299    normalized.dedup();
300    normalized
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use base64::Engine;
307    use hashtree_cli::HashtreeStore;
308    use hashtree_core::{from_hex, DirEntry, HashTree, HashTreeConfig, LinkType};
309    use nostr::{nips::nip19::ToBech32, EventBuilder, Keys, Kind, Tag, TagKind, Timestamp};
310    use reqwest::blocking::Client;
311    use serde_json::json;
312    use std::io::{Read, Write};
313    use std::net::{TcpListener, TcpStream};
314    use std::sync::Arc;
315    use std::thread;
316    use tempfile::TempDir;
317
318    fn create_blossom_auth(keys: &Keys, action: &str) -> String {
319        let expiration = Timestamp::from(Timestamp::now().as_secs() + 300);
320        let tags = vec![
321            Tag::custom(TagKind::Custom("t".into()), vec![action.to_string()]),
322            Tag::custom(
323                TagKind::Custom("expiration".into()),
324                vec![expiration.to_string()],
325            ),
326        ];
327        let event = EventBuilder::new(Kind::Custom(24242), "")
328            .tags(tags)
329            .sign_with_keys(keys)
330            .expect("sign blossom auth");
331        let encoded = base64::engine::general_purpose::STANDARD
332            .encode(serde_json::to_string(&event).expect("serialize auth event"));
333        format!("Nostr {encoded}")
334    }
335
336    fn start_blob_fixture_server(store: Arc<HashtreeStore>) -> String {
337        let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture blob server");
338        let addr = listener.local_addr().expect("fixture blob server addr");
339        thread::spawn(move || {
340            for stream in listener.incoming().flatten() {
341                let store = store.clone();
342                thread::spawn(move || handle_blob_fixture_request(stream, store));
343            }
344        });
345        format!("http://{addr}")
346    }
347
348    fn handle_blob_fixture_request(mut stream: TcpStream, store: Arc<HashtreeStore>) {
349        let mut buffer = [0_u8; 2048];
350        let bytes_read = match stream.read(&mut buffer) {
351            Ok(bytes_read) => bytes_read,
352            Err(_) => return,
353        };
354        let request = String::from_utf8_lossy(&buffer[..bytes_read]);
355        let path = request
356            .lines()
357            .next()
358            .and_then(|line| line.split_whitespace().nth(1))
359            .unwrap_or("/");
360        let Some(hash_hex) = path
361            .trim_start_matches('/')
362            .strip_suffix(".bin")
363            .filter(|hash_hex| hash_hex.len() == 64)
364        else {
365            let _ = stream.write_all(
366                b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
367            );
368            return;
369        };
370        let Ok(hash) = from_hex(hash_hex) else {
371            let _ = stream.write_all(
372                b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
373            );
374            return;
375        };
376        let data = match store.get_blob(&hash) {
377            Ok(Some(data)) => data,
378            _ => {
379                let _ = stream.write_all(
380                    b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
381                );
382                return;
383            }
384        };
385        let header = format!(
386            "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
387            data.len()
388        );
389        let _ = stream.write_all(header.as_bytes());
390        let _ = stream.write_all(&data);
391    }
392
393    #[test]
394    fn host_runtime_starts_and_shuts_down() {
395        let temp = TempDir::new().expect("temp dir");
396        let mut runtime =
397            HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
398
399        let status = runtime.status();
400        assert!(
401            status.config_dir.join("keys").exists(),
402            "expected host daemon to materialize keys in its config dir"
403        );
404
405        let response = reqwest::blocking::get(format!("{}/htree/test", status.base_url))
406            .expect("fetch test endpoint");
407        assert!(
408            response.status().is_success(),
409            "embedded daemon should answer"
410        );
411
412        runtime.shutdown();
413
414        let stopped = reqwest::blocking::get(format!("{}/htree/test", status.base_url)).is_err();
415        assert!(stopped, "expected host daemon shutdown to stop serving");
416
417        runtime.shutdown();
418    }
419
420    #[test]
421    fn host_runtime_rejects_public_blossom_uploads() {
422        let temp = TempDir::new().expect("temp dir");
423        let runtime =
424            HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
425        let status = runtime.status();
426
427        let keys = Keys::generate();
428        let response = Client::new()
429            .put(format!("{}/upload", status.base_url))
430            .header("Authorization", create_blossom_auth(&keys, "upload"))
431            .header("Content-Type", "text/plain")
432            .body("browser-mode upload probe")
433            .send()
434            .expect("upload request");
435
436        assert_eq!(
437            response.status(),
438            reqwest::StatusCode::FORBIDDEN,
439            "browser-mode daemon must not accept public Blossom uploads"
440        );
441    }
442
443    #[test]
444    fn host_runtime_keeps_default_relays_and_file_servers() {
445        let temp = TempDir::new().expect("temp dir");
446        let runtime =
447            HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
448        let status = runtime.status();
449
450        let response = reqwest::blocking::get(format!("{}/api/status", status.base_url))
451            .expect("fetch daemon status");
452        assert!(
453            response.status().is_success(),
454            "status endpoint should answer"
455        );
456        let payload: serde_json::Value = response.json().expect("parse daemon status");
457
458        assert!(
459            payload["upstream"]["nostr_relays"]
460                .as_u64()
461                .unwrap_or_default()
462                > 0,
463            "browser mode should keep default relays for profile lookups",
464        );
465        assert!(
466            payload["upstream"]["blossom_servers"]
467                .as_u64()
468                .unwrap_or_default()
469                > 0,
470            "browser mode should keep default file servers for content fetches",
471        );
472        assert!(
473            payload.get("mesh").is_none() && payload.get("webrtc").is_none(),
474            "the daemon status must not expose the removed legacy mesh status",
475        );
476    }
477
478    #[test]
479    fn host_runtime_serves_initial_keyed_root_from_upstream_blossom() {
480        let temp = TempDir::new().expect("temp dir");
481        let source_dir = TempDir::new().expect("source temp dir");
482        let source_store = Arc::new(
483            HashtreeStore::new(source_dir.path().join("source-db")).expect("source store"),
484        );
485        let setup_runtime = tokio::runtime::Builder::new_current_thread()
486            .enable_all()
487            .build()
488            .expect("setup runtime");
489        let (root_cid, expected_body) = setup_runtime.block_on(async {
490            let source_tree = HashTree::new(HashTreeConfig::new(source_store.store_arc()));
491            let expected_body =
492                b"<!doctype html><title>Iris Apps</title><main>Drive Chat Contacts</main>".to_vec();
493            let (index_cid, _) = source_tree.put(&expected_body).await.expect("index cid");
494            let root_cid = source_tree
495                .put_directory(vec![
496                    DirEntry::from_cid("index.html", &index_cid).with_link_type(LinkType::File)
497                ])
498                .await
499                .expect("root directory cid");
500            (root_cid, expected_body)
501        });
502        assert!(
503            root_cid.key.is_some(),
504            "test must cover a keyed/private site root"
505        );
506
507        let upstream = start_blob_fixture_server(source_store);
508        let route_keys = Keys::generate();
509        let npub = route_keys.public_key().to_bech32().expect("encode npub");
510        let config_dir = temp.path().join("config");
511        std::fs::create_dir_all(&config_dir).expect("create config dir");
512        std::fs::write(
513            config_dir.join("browser_settings.json"),
514            serde_json::to_vec_pretty(&json!({
515                "nostrRelays": [],
516                "blossomReadServers": [upstream],
517                "blossomWriteServers": [],
518                "enableFips": false,
519                "enableFipsUdp": false,
520                "enableFipsWebrtc": false,
521                "fetchFromFipsPeers": false,
522                "socialGraphCrawlDepth": 0,
523                "syncEnabled": false,
524                "allowedNpubs": [npub.clone()]
525            }))
526            .expect("serialize browser settings"),
527        )
528        .expect("write browser settings");
529
530        let mut runtime = HostDaemonRuntime::start(
531            HostDaemonOptions::new(temp.path())
532                .with_initial_tree_roots(vec![(format!("{npub}/sites"), root_cid)]),
533        )
534        .expect("start daemon");
535        let status = runtime.status();
536
537        let response = Client::new()
538            .get(format!("{}/htree/{npub}/sites/index.html", status.base_url))
539            .send()
540            .expect("fetch initial rooted site");
541        assert_eq!(response.status(), reqwest::StatusCode::OK);
542        assert_eq!(
543            response.bytes().expect("site body").as_ref(),
544            expected_body.as_slice()
545        );
546
547        runtime.shutdown();
548    }
549
550    #[test]
551    fn host_runtime_applies_browser_settings_overrides() {
552        let temp = TempDir::new().expect("temp dir");
553        let config_dir = temp.path().join("config");
554        std::fs::create_dir_all(&config_dir).expect("create config dir");
555        std::fs::write(
556            config_dir.join("browser_settings.json"),
557            serde_json::to_vec_pretty(&json!({
558                "nostrRelays": [
559                    "wss://relay.example-two",
560                    "wss://relay.example-one",
561                    "wss://relay.example-two"
562                ],
563                "blossomReadServers": [
564                    "https://cdn.example"
565                ],
566                "blossomWriteServers": [
567                    "https://upload.example"
568                ],
569                "enableWebrtc": true,
570                "enableFips": false,
571                "enableFipsUdp": false,
572                "enableFipsWebrtc": false,
573                "fetchFromFipsPeers": false,
574                "socialGraphCrawlDepth": 0,
575                "syncEnabled": false,
576                "syncOwn": false,
577                "syncFollowed": false
578            }))
579            .expect("serialize browser settings"),
580        )
581        .expect("write browser settings");
582
583        let runtime =
584            HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
585        let status = runtime.status();
586
587        let response = reqwest::blocking::get(format!("{}/api/status", status.base_url))
588            .expect("fetch daemon status");
589        assert!(
590            response.status().is_success(),
591            "status endpoint should answer"
592        );
593        let payload: serde_json::Value = response.json().expect("parse daemon status");
594
595        assert_eq!(payload["upstream"]["nostr_relays"].as_u64(), Some(2));
596        assert_eq!(payload["upstream"]["blossom_servers"].as_u64(), Some(2));
597        assert!(
598            payload.get("mesh").is_none() && payload.get("webrtc").is_none(),
599            "legacy WebRTC settings must not recreate legacy mesh status",
600        );
601    }
602
603    #[test]
604    fn browser_config_applies_background_service_overrides() {
605        let temp = TempDir::new().expect("temp dir");
606        let config_dir = temp.path().join("config");
607        let data_dir = temp.path().join("data");
608        std::fs::create_dir_all(&config_dir).expect("create config dir");
609        std::fs::write(
610            config_dir.join("browser_settings.json"),
611            serde_json::to_vec_pretty(&json!({
612                "enableFips": false,
613                "enableFipsUdp": false,
614                "enableFipsWebrtc": false,
615                "fetchFromFipsPeers": false,
616                "socialGraphCrawlDepth": 0
617            }))
618            .expect("serialize browser settings"),
619        )
620        .expect("write browser settings");
621
622        let config = browser_config(&data_dir, &config_dir);
623
624        assert!(!config.server.enable_fips);
625        assert!(!config.server.enable_fips_udp);
626        assert!(!config.server.enable_fips_webrtc);
627        assert!(!config.server.fetch_from_fips_peers);
628        assert_eq!(config.nostr.social_graph_crawl_depth, 0);
629    }
630
631    #[test]
632    fn browser_config_uses_browser_sized_database_limits() {
633        let temp = TempDir::new().expect("temp dir");
634        let config_dir = temp.path().join("config");
635        let data_dir = temp.path().join("data");
636        std::fs::create_dir_all(&config_dir).expect("create config dir");
637
638        let config = browser_config(&data_dir, &config_dir);
639
640        assert_eq!(config.storage.max_size_gb, BROWSER_STORAGE_MAX_SIZE_GB);
641        assert_eq!(
642            config.nostr.db_max_size_gb,
643            BROWSER_SOCIAL_GRAPH_DB_MAX_SIZE_GB
644        );
645        assert_eq!(
646            config.nostr.spambox_max_size_gb,
647            BROWSER_SPAMBOX_DB_MAX_SIZE_GB
648        );
649    }
650
651    #[test]
652    fn browser_config_applies_database_limit_overrides() {
653        let temp = TempDir::new().expect("temp dir");
654        let config_dir = temp.path().join("config");
655        let data_dir = temp.path().join("data");
656        std::fs::create_dir_all(&config_dir).expect("create config dir");
657        std::fs::write(
658            config_dir.join("browser_settings.json"),
659            serde_json::to_vec_pretty(&json!({
660                "storageMaxSizeGb": 2,
661                "socialGraphDbMaxSizeGb": 2,
662                "spamboxDbMaxSizeGb": 1
663            }))
664            .expect("serialize browser settings"),
665        )
666        .expect("write browser settings");
667
668        let config = browser_config(&data_dir, &config_dir);
669
670        assert_eq!(config.storage.max_size_gb, 2);
671        assert_eq!(config.nostr.db_max_size_gb, 2);
672        assert_eq!(config.nostr.spambox_max_size_gb, 1);
673    }
674
675    #[test]
676    fn host_runtime_reload_applies_updated_browser_settings() {
677        let temp = TempDir::new().expect("temp dir");
678        let mut runtime =
679            HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
680        let initial_status = runtime.status();
681
682        let config_dir = temp.path().join("config");
683        std::fs::create_dir_all(&config_dir).expect("create config dir");
684        std::fs::write(
685            config_dir.join("browser_settings.json"),
686            serde_json::to_vec_pretty(&json!({
687                "nostrRelays": ["wss://relay.example-one"],
688                "blossomReadServers": ["https://cdn.example"],
689                "blossomWriteServers": ["https://upload.example"],
690                "enableWebrtc": true
691            }))
692            .expect("serialize browser settings"),
693        )
694        .expect("write browser settings");
695
696        let reloaded_status = runtime.reload().expect("reload daemon");
697        let response = reqwest::blocking::get(format!("{}/api/status", reloaded_status.base_url))
698            .expect("fetch daemon status after reload");
699        assert!(
700            response.status().is_success(),
701            "reloaded daemon should answer"
702        );
703        let payload: serde_json::Value = response.json().expect("parse daemon status");
704
705        assert_eq!(payload["upstream"]["nostr_relays"].as_u64(), Some(1));
706        assert_eq!(payload["upstream"]["blossom_servers"].as_u64(), Some(2));
707        assert!(
708            payload.get("mesh").is_none() && payload.get("webrtc").is_none(),
709            "reload must not recreate legacy mesh status when browser settings request WebRTC",
710        );
711        assert!(
712            !reloaded_status.base_url.is_empty(),
713            "reload should keep serving from some loopback endpoint"
714        );
715        assert_eq!(
716            reloaded_status.self_npub, initial_status.self_npub,
717            "reload should keep the same browser-owned identity"
718        );
719    }
720}