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