Skip to main content

hashtree_cli/server/
mod.rs

1mod auth;
2mod blob_read;
3pub(crate) use blob_read::run_blob_read;
4pub mod blossom;
5mod handlers;
6mod ingest_filter;
7mod mime;
8mod nostr_query;
9mod peer_status;
10#[cfg(all(test, feature = "lmdb"))]
11mod pin_tree_tests;
12mod request_paths;
13mod status_metrics;
14mod ui;
15pub mod ws_relay;
16
17use crate::nostr_relay::NostrRelay;
18use crate::socialgraph;
19use crate::storage::HashtreeStore;
20use anyhow::Result;
21use axum::{
22    body::Body,
23    extract::{DefaultBodyLimit, State},
24    http::{header, HeaderValue, Method, Request, StatusCode},
25    middleware,
26    middleware::Next,
27    response::{IntoResponse, Json, Response},
28    routing::{get, post, put},
29    Router,
30};
31use futures::{future::poll_fn, pin_mut, FutureExt};
32use hashtree_core::Cid;
33use hyper::body::Incoming;
34use hyper_util::{
35    rt::{TokioExecutor, TokioIo, TokioTimer},
36    server::conn::auto::Builder as HyperBuilder,
37    service::TowerToHyperService,
38};
39use socket2::{SockRef, TcpKeepalive};
40use std::collections::{HashMap, HashSet};
41use std::convert::Infallible;
42use std::future;
43use std::io;
44use std::net::SocketAddr;
45use std::sync::{Arc, OnceLock, RwLock};
46use std::time::{Duration, Instant};
47use tokio::sync::watch;
48use tower::{Service, ServiceExt as _};
49use tower_http::cors::CorsLayer;
50use tracing::{debug, error, trace};
51
52pub use auth::{
53    new_lookup_cache, new_upstream_http_client, AppState, AuthCredentials, CachedTreeRootEntry,
54};
55
56static VIRTUAL_TREE_HOSTS: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
57const DEFAULT_OPTIMISTIC_UPLOAD_QUEUE_BYTES: usize = 256 * 1024 * 1024;
58const DEFAULT_BLOSSOM_UPLOAD_REPLICA_QUEUE_BYTES: usize = 256 * 1024 * 1024;
59const INTERNAL_JSON_BODY_LIMIT_BYTES: usize = 64 * 1024;
60const POOL_AUDIT_READ_ONLY_HTTP_ERROR: &str = "PoolStore is in audit-serving read-only mode";
61const POOL_AUDIT_READ_ONLY_REASON: &str = "pool-audit-read-only";
62const POOL_AUDIT_READ_ONLY_REASON_HEADER: &str = "x-hashtree-maintenance-reason";
63
64#[cfg(not(test))]
65const HTTP1_HEADER_READ_TIMEOUT: Duration = Duration::from_secs(30);
66#[cfg(test)]
67const HTTP1_HEADER_READ_TIMEOUT: Duration = Duration::from_millis(200);
68const HTTP2_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30);
69const HTTP2_KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(10);
70const TCP_LISTEN_BACKLOG: i32 = 1_024;
71const TCP_KEEPALIVE_TIME: Duration = Duration::from_secs(60);
72const TCP_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(15);
73
74pub fn bounded_upload_queue_bytes(bytes: u64) -> usize {
75    usize::try_from(bytes)
76        .unwrap_or(usize::MAX)
77        .clamp(1, tokio::sync::Semaphore::MAX_PERMITS)
78}
79
80fn virtual_tree_hosts() -> &'static RwLock<HashMap<String, String>> {
81    VIRTUAL_TREE_HOSTS.get_or_init(|| RwLock::new(HashMap::new()))
82}
83
84fn pool_audit_request_is_allowed(method: &Method, path: &str) -> bool {
85    if path == "/ws" || path == "/ws/" {
86        return false;
87    }
88    matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS)
89        || (*method == Method::POST && matches!(path, "/blob/batch" | "/upload/check"))
90}
91
92async fn pool_audit_read_only_middleware(
93    State(state): State<AppState>,
94    request: Request<Body>,
95    next: Next,
96) -> Response {
97    if !state.store.is_pool_audit_read_only()
98        || pool_audit_request_is_allowed(request.method(), request.uri().path())
99    {
100        return next.run(request).await;
101    }
102
103    let mut response = (
104        StatusCode::SERVICE_UNAVAILABLE,
105        Json(serde_json::json!({
106            "error": POOL_AUDIT_READ_ONLY_HTTP_ERROR,
107        })),
108    )
109        .into_response();
110    response.headers_mut().insert(
111        header::HeaderName::from_static(POOL_AUDIT_READ_ONLY_REASON_HEADER),
112        HeaderValue::from_static(POOL_AUDIT_READ_ONLY_REASON),
113    );
114    response
115}
116
117fn normalize_virtual_tree_host(host: &str) -> Option<String> {
118    let trimmed = host.trim().trim_end_matches('.').to_ascii_lowercase();
119    if trimmed.is_empty() {
120        return None;
121    }
122
123    if let Some(stripped) = trimmed
124        .strip_prefix('[')
125        .and_then(|value| value.split_once(']'))
126    {
127        let host_only = stripped.0.trim();
128        if host_only.is_empty() {
129            return None;
130        }
131        return Some(host_only.to_string());
132    }
133
134    if let Some((host_only, port)) = trimmed.rsplit_once(':') {
135        if !host_only.is_empty() && !port.is_empty() && port.chars().all(|ch| ch.is_ascii_digit()) {
136            return Some(host_only.to_string());
137        }
138    }
139
140    Some(trimmed)
141}
142
143pub fn register_virtual_tree_host(host: &str, internal_root: &str) {
144    let Some(normalized_host) = normalize_virtual_tree_host(host) else {
145        return;
146    };
147
148    let normalized_root = internal_root.trim().trim_end_matches('/');
149    if normalized_root.is_empty() {
150        return;
151    }
152
153    if let Ok(mut hosts) = virtual_tree_hosts().write() {
154        hosts.insert(normalized_host, normalized_root.to_string());
155    }
156}
157
158pub fn resolve_virtual_tree_host(host: &str) -> Option<String> {
159    let normalized_host = normalize_virtual_tree_host(host)?;
160    let configured = virtual_tree_hosts()
161        .read()
162        .ok()
163        .and_then(|hosts| hosts.get(&normalized_host).cloned());
164    configured.or_else(|| resolve_iris_localhost_tree_root(&normalized_host))
165}
166
167fn resolve_iris_localhost_tree_root(host: &str) -> Option<String> {
168    let labels: Vec<&str> = host.strip_suffix(".iris.localhost")?.split('.').collect();
169    match labels.as_slice() {
170        [nhash] if nhash.starts_with("nhash1") => Some(format!("/htree/{nhash}")),
171        [site, npub] if !site.is_empty() && npub.starts_with("npub1") => {
172            Some(format!("/htree/{npub}/{site}"))
173        }
174        _ => None,
175    }
176}
177
178#[cfg(test)]
179pub fn clear_virtual_tree_hosts_for_test() {
180    if let Ok(mut hosts) = virtual_tree_hosts().write() {
181        hosts.clear();
182    }
183}
184
185pub struct HashtreeServer {
186    state: AppState,
187    addr: String,
188    extra_routes: Option<Router<AppState>>,
189    cors: Option<CorsLayer>,
190}
191
192impl HashtreeServer {
193    pub fn new(store: Arc<HashtreeStore>, addr: String) -> Self {
194        Self {
195            state: AppState {
196                store,
197                auth: None,
198                daemon_started_at: current_unix_secs(),
199                peer_mode: crate::config::ServerMode::Normal,
200                hash_get_enabled: true,
201                fips_endpoint: None,
202                fips_blob_resolver: None,
203                fetch_from_fips_peers: true,
204                ws_relay: Arc::new(auth::WsRelayState::new()),
205                max_upload_bytes: 5 * 1024 * 1024, // 5 MB default
206                public_writes: true,               // Allow anyone with valid Nostr auth by default
207                public_plaintext_reads: false,
208                require_random_untrusted_ingest: true,
209                optimistic_blossom_uploads: false,
210                optimistic_upload_queue_bytes: DEFAULT_OPTIMISTIC_UPLOAD_QUEUE_BYTES,
211                optimistic_upload_queue: Arc::new(tokio::sync::Semaphore::new(
212                    DEFAULT_OPTIMISTIC_UPLOAD_QUEUE_BYTES,
213                )),
214                allowed_pubkeys: HashSet::new(), // No pubkeys allowed by default (use public_writes)
215                upstream_blossom: Vec::new(),
216                upstream_http_client: new_upstream_http_client(),
217                upstream_blossom_miss_cache: Arc::new(std::sync::Mutex::new(new_lookup_cache())),
218                upstream_blossom_fetch_metrics: Arc::new(
219                    auth::UpstreamBlossomFetchMetrics::default(),
220                ),
221                blossom_upload_replicas: Vec::new(),
222                blossom_upload_replica_queue_bytes: DEFAULT_BLOSSOM_UPLOAD_REPLICA_QUEUE_BYTES,
223                blossom_upload_replica_queue: Arc::new(tokio::sync::Semaphore::new(
224                    DEFAULT_BLOSSOM_UPLOAD_REPLICA_QUEUE_BYTES,
225                )),
226                blossom_upload_replica_keys: None,
227                blossom_upload_replica_scheduler: Arc::new(
228                    blossom::BlossomUploadReplicaScheduler::new(),
229                ),
230                social_graph: None,
231                social_graph_store: None,
232                social_graph_root: None,
233                socialgraph_snapshot_public: false,
234                nostr_relay: None,
235                nostr_provider: None,
236                nostr_event_transport: None,
237                nostr_relay_urls: Vec::new(),
238                tree_root_cache: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
239                inflight_blob_fetches: Arc::new(tokio::sync::Mutex::new(
240                    std::collections::HashMap::new(),
241                )),
242                inflight_blob_reads: Arc::new(tokio::sync::Mutex::new(
243                    std::collections::HashMap::new(),
244                )),
245                blob_cache: Arc::new(crate::blob_cache::BlobCache::from_env()),
246                directory_listing_cache: Arc::new(std::sync::Mutex::new(new_lookup_cache())),
247                resolved_path_cache: Arc::new(std::sync::Mutex::new(new_lookup_cache())),
248                thumbnail_path_cache: Arc::new(std::sync::Mutex::new(new_lookup_cache())),
249                cid_size_cache: Arc::new(std::sync::Mutex::new(new_lookup_cache())),
250            },
251            addr,
252            extra_routes: None,
253            cors: None,
254        }
255    }
256
257    /// Set maximum upload size for Blossom uploads
258    pub fn with_max_upload_bytes(mut self, bytes: usize) -> Self {
259        self.state.max_upload_bytes = bytes;
260        self
261    }
262
263    /// Set whether to allow public writes (anyone with valid Nostr auth)
264    /// When false, only social graph members can write
265    pub fn with_public_writes(mut self, public: bool) -> Self {
266        self.state.public_writes = public;
267        self
268    }
269
270    /// Set whether mutable npub routes serve plaintext for unapproved pubkeys
271    pub fn with_public_plaintext_reads(mut self, public: bool) -> Self {
272        self.state.public_plaintext_reads = public;
273        self
274    }
275
276    pub fn with_require_random_untrusted_ingest(mut self, require: bool) -> Self {
277        self.state.require_random_untrusted_ingest = require;
278        self
279    }
280
281    pub fn with_optimistic_blossom_uploads(mut self, enabled: bool) -> Self {
282        self.state.optimistic_blossom_uploads = enabled;
283        self
284    }
285
286    pub fn with_server_mode(mut self, mode: crate::config::ServerMode) -> Self {
287        self.state.peer_mode = mode;
288        self
289    }
290
291    pub fn with_hash_get_enabled(mut self, enabled: bool) -> Self {
292        self.state.hash_get_enabled = enabled;
293        self
294    }
295
296    pub fn with_fetch_from_fips_peers(mut self, enabled: bool) -> Self {
297        self.state.fetch_from_fips_peers = enabled;
298        self
299    }
300
301    pub fn with_fips_endpoint(
302        mut self,
303        endpoint: Arc<hashtree_fips_transport::FipsEndpoint>,
304    ) -> Self {
305        self.state.fips_endpoint = Some(endpoint);
306        self
307    }
308
309    pub fn with_fips_blob_resolver(
310        mut self,
311        resolver: Arc<crate::fips_transport::DaemonBlobResolver>,
312    ) -> Self {
313        self.state.fips_blob_resolver = Some(resolver);
314        self
315    }
316
317    pub fn with_auth(mut self, username: String, password: String) -> Self {
318        self.state.auth = Some(AuthCredentials { username, password });
319        self
320    }
321
322    /// Set allowed pubkeys for blossom write access (hex format)
323    pub fn with_allowed_pubkeys(mut self, pubkeys: HashSet<String>) -> Self {
324        self.state.allowed_pubkeys = pubkeys;
325        self
326    }
327
328    /// Set upstream Blossom servers for cascade fetching
329    pub fn with_upstream_blossom(mut self, servers: Vec<String>) -> Self {
330        self.state.upstream_blossom = servers;
331        self
332    }
333
334    /// Set write-behind Blossom servers for blobs accepted by this server.
335    pub fn with_blossom_upload_replicas(
336        mut self,
337        servers: Vec<String>,
338        queue_bytes: usize,
339        keys: nostr::Keys,
340    ) -> Self {
341        let queue_bytes = queue_bytes.clamp(1, tokio::sync::Semaphore::MAX_PERMITS);
342        let mut servers: Vec<String> = servers
343            .into_iter()
344            .map(|server| server.trim().trim_end_matches('/').to_string())
345            .filter(|server| !server.is_empty())
346            .collect();
347        servers.sort();
348        servers.dedup();
349        let replica_keys = (!servers.is_empty()).then(|| Arc::new(keys));
350        self.state.blossom_upload_replicas = servers;
351        self.state.blossom_upload_replica_queue_bytes = queue_bytes;
352        self.state.blossom_upload_replica_queue =
353            Arc::new(tokio::sync::Semaphore::new(queue_bytes));
354        self.state.blossom_upload_replica_keys = replica_keys;
355        self
356    }
357
358    /// Set social graph access control
359    pub fn with_social_graph(mut self, sg: Arc<socialgraph::SocialGraphAccessControl>) -> Self {
360        self.state.social_graph = Some(sg);
361        self
362    }
363
364    /// Configure social graph snapshot export (store handle + root)
365    pub fn with_socialgraph_snapshot(
366        mut self,
367        store: Arc<dyn socialgraph::SocialGraphBackend>,
368        root: [u8; 32],
369        public: bool,
370    ) -> Self {
371        self.state.social_graph_store = Some(store);
372        self.state.social_graph_root = Some(root);
373        self.state.socialgraph_snapshot_public = public;
374        self
375    }
376
377    /// Set Nostr relay state (shared for /ws and WebRTC)
378    pub fn with_nostr_relay(mut self, relay: Arc<NostrRelay>) -> Self {
379        self.state.nostr_relay = Some(relay);
380        self
381    }
382
383    pub fn with_nostr_provider(mut self, provider: Arc<dyn nostr_pubsub::PubsubProvider>) -> Self {
384        self.state.nostr_provider = Some(provider);
385        self
386    }
387
388    /// Declare which configured transport is served by the event provider.
389    pub fn with_nostr_event_transport(
390        mut self,
391        transport: crate::config::NostrEventTransport,
392    ) -> Self {
393        self.state.nostr_event_transport = Some(transport);
394        self
395    }
396
397    /// Set active upstream Nostr relays for HTTP resolver operations.
398    pub fn with_nostr_relay_urls(mut self, relays: Vec<String>) -> Self {
399        self.state.nostr_relay_urls = relays;
400        self
401    }
402
403    /// Seed mutable root cache entries before the server starts.
404    pub fn with_cached_tree_roots(self, roots: Vec<(String, Cid)>) -> Self {
405        if let Ok(mut cache) = self.state.tree_root_cache.lock() {
406            let now = Instant::now();
407            for (key, cid) in roots {
408                cache.insert(
409                    key,
410                    CachedTreeRootEntry {
411                        cid,
412                        source: "embedded-bootstrap",
413                        root_event: None,
414                        event: None,
415                        cached_at: now,
416                    },
417                );
418            }
419        }
420        self
421    }
422
423    /// Merge extra routes into the daemon router (e.g. Tauri embeds /nip07).
424    pub fn with_extra_routes(mut self, routes: Router<AppState>) -> Self {
425        self.extra_routes = Some(routes);
426        self
427    }
428
429    /// Apply a CORS layer to all routes (used by embedded clients like Tauri).
430    pub fn with_cors(mut self, cors: CorsLayer) -> Self {
431        self.cors = Some(cors);
432        self
433    }
434
435    pub async fn run(self) -> Result<()> {
436        let listener = tokio::net::TcpListener::bind(&self.addr).await?;
437        let _ = self.run_with_listener(listener).await?;
438        Ok(())
439    }
440
441    pub async fn run_with_listener(self, listener: tokio::net::TcpListener) -> Result<u16> {
442        self.run_with_listener_until(listener, future::pending::<()>())
443            .await
444    }
445
446    pub async fn run_with_listener_until<F>(
447        self,
448        listener: tokio::net::TcpListener,
449        shutdown: F,
450    ) -> Result<u16>
451    where
452        F: std::future::Future<Output = ()> + Send + 'static,
453    {
454        // `tokio::net::TcpListener::bind` inherits the platform's small
455        // default listen backlog (128 on Linux). A daemon restart can attract
456        // more reconnecting CDN/tunnel sockets than that before the first
457        // requests are accepted, leaving otherwise healthy clients stalled.
458        // Re-listening updates the queue limit without replacing the bound
459        // socket; the kernel still applies its configured `somaxconn` cap.
460        SockRef::from(&listener).listen(TCP_LISTEN_BACKLOG)?;
461        let local_addr = listener.local_addr()?;
462
463        // Public endpoints (no auth required)
464        // Note: /:id serves raw SHA256 blobs only. Logical tree/file assembly
465        // stays on explicitly configured mutable routes such as approved npub.
466        let state = self.state.clone();
467        let public_routes = Router::new()
468            .route("/", get(handlers::serve_root_or_virtual_host))
469            .route("/ws", get(ws_relay::ws_data))
470            .route("/ws/", get(ws_relay::ws_data))
471            .route(
472                "/__iris/store/:hash",
473                get(handlers::iris_store_get).head(handlers::iris_store_head),
474            )
475            .route(
476                "/htree/test",
477                get(handlers::htree_test).head(handlers::htree_test),
478            )
479            // /htree/nhash1...[/path] - content-addressed (immutable)
480            .route("/htree/nhash1:nhash", get(handlers::htree_nhash))
481            .route("/htree/nhash1:nhash/", get(handlers::htree_nhash))
482            .route("/htree/nhash1:nhash/*path", get(handlers::htree_nhash_path))
483            // /htree/npub1.../tree[/path] - mutable (resolver-backed)
484            .route("/htree/npub1:npub/:treename", get(handlers::htree_npub))
485            .route("/htree/npub1:npub/:treename/", get(handlers::htree_npub))
486            .route(
487                "/htree/npub1:npub/:treename/*path",
488                get(handlers::htree_npub_path),
489            )
490            // Nostr resolver endpoints - resolve npub/treename to content
491            .route("/n/:pubkey/:treename", get(handlers::resolve_and_serve))
492            // Direct npub route (clients should parse nhash and request by hex hash)
493            .route("/npub1:rest", get(handlers::serve_npub))
494            .route("/npub1:rest/*path", get(handlers::serve_npub))
495            // Blossom endpoints (BUD-01, BUD-02)
496            .route(
497                "/:id",
498                get(handlers::serve_content_or_blob)
499                    .head(blossom::head_blob)
500                    .delete(blossom::delete_blob)
501                    .options(blossom::cors_preflight),
502            )
503            .route(
504                "/upload",
505                put(blossom::upload_blob)
506                    .layer::<_, std::convert::Infallible>(middleware::from_fn(
507                        blossom::require_upload_auth_middleware,
508                    ))
509                    .layer(DefaultBodyLimit::max(blossom::MAX_SINGLE_UPLOAD_BODY_BYTES))
510                    .head(blossom::head_upload)
511                    .options(blossom::cors_preflight),
512            )
513            .route(
514                "/upload/batch",
515                post(blossom::upload_blob_batch)
516                    .layer::<_, std::convert::Infallible>(middleware::from_fn(
517                        blossom::require_upload_auth_middleware,
518                    ))
519                    .options(blossom::cors_preflight)
520                    .layer(DefaultBodyLimit::max(
521                        blossom::MAX_BATCH_UPLOAD_JSON_BODY_BYTES,
522                    )),
523            )
524            .route(
525                "/upload/batch-binary",
526                post(blossom::upload_blob_batch_binary)
527                    .layer::<_, std::convert::Infallible>(middleware::from_fn(
528                        blossom::require_upload_auth_middleware,
529                    ))
530                    .options(blossom::cors_preflight)
531                    .layer(DefaultBodyLimit::max(
532                        blossom::MAX_BATCH_UPLOAD_BINARY_BODY_BYTES,
533                    )),
534            )
535            .route(
536                "/upload/check",
537                post(blossom::upload_check).options(blossom::cors_preflight),
538            )
539            .route(
540                "/blob/batch",
541                post(handlers::download_blob_batch).options(blossom::cors_preflight),
542            )
543            .route(
544                "/list/:pubkey",
545                get(blossom::list_blobs).options(blossom::cors_preflight),
546            )
547            // Hashtree API endpoints
548            .route("/health", get(handlers::health_check))
549            .route("/api/pins", get(handlers::list_pins))
550            .route("/api/stats", get(handlers::storage_stats))
551            .route("/api/status", get(handlers::daemon_status))
552            .route("/api/socialgraph", get(handlers::socialgraph_stats))
553            .route(
554                "/api/socialgraph/snapshot",
555                get(handlers::socialgraph_snapshot),
556            )
557            .route(
558                "/api/socialgraph/distance/:pubkey",
559                get(handlers::follow_distance),
560            )
561            // Resolver API endpoints
562            .route(
563                "/api/resolve/:pubkey/:treename",
564                get(handlers::resolve_to_hash),
565            )
566            .route(
567                "/api/nostr/resolve/:pubkey/:treename",
568                get(handlers::resolve_to_hash),
569            )
570            .route("/api/nostr/profile/:pubkey", get(handlers::nostr_profile))
571            .route(
572                "/api/nostr/events",
573                post(handlers::publish_nostr_event)
574                    .layer(DefaultBodyLimit::max(INTERNAL_JSON_BODY_LIMIT_BYTES)),
575            )
576            .route("/api/trees/:pubkey", get(handlers::list_trees))
577            .fallback(get(handlers::serve_virtual_host_fallback))
578            .with_state(state.clone());
579
580        // Protected endpoints (require auth if enabled)
581        let protected_routes = Router::new()
582            .route("/upload", post(handlers::upload_file))
583            .route("/api/pin/:cid", post(handlers::pin_cid))
584            .route("/api/unpin/:cid", post(handlers::unpin_cid))
585            .route("/api/gc", post(handlers::garbage_collect))
586            .layer(middleware::from_fn_with_state(
587                state.clone(),
588                auth::auth_middleware,
589            ))
590            .with_state(state.clone());
591
592        // Internal mutating endpoints require configured Basic auth. These
593        // routes stay closed even when optional API auth is disabled.
594        let internal_routes = Router::new()
595            .route(
596                "/__iris/store/:hash",
597                put(handlers::iris_store_put)
598                    .delete(handlers::iris_store_delete)
599                    .layer(DefaultBodyLimit::max(blossom::MAX_BATCH_UPLOAD_BYTES)),
600            )
601            .route(
602                "/api/pin-tree",
603                post(handlers::pin_tree)
604                    .layer(DefaultBodyLimit::max(INTERNAL_JSON_BODY_LIMIT_BYTES)),
605            )
606            .route(
607                "/api/cache-tree-root",
608                post(handlers::cache_tree_root)
609                    .layer(DefaultBodyLimit::max(INTERNAL_JSON_BODY_LIMIT_BYTES)),
610            )
611            .route(
612                "/api/clear-tree-root-cache",
613                post(handlers::clear_tree_root_cache)
614                    .layer(DefaultBodyLimit::max(INTERNAL_JSON_BODY_LIMIT_BYTES)),
615            )
616            .layer(middleware::from_fn_with_state(
617                state.clone(),
618                auth::require_auth_middleware,
619            ))
620            .with_state(state.clone());
621
622        let mut app = public_routes
623            .merge(protected_routes)
624            .merge(internal_routes)
625            .layer(DefaultBodyLimit::max(10 * 1024 * 1024 * 1024)); // 10GB limit
626
627        if let Some(extra) = self.extra_routes {
628            app = app.merge(extra.with_state(state.clone()));
629        }
630
631        // This gate is deliberately outside every route-level auth/body
632        // layer, including caller-supplied routes. Audit mode rejects
633        // mutation ingress before any handler can alter blob or metadata
634        // state. Status metrics remain outermost so maintenance responses are
635        // still observable.
636        app = app
637            .layer(middleware::from_fn_with_state(
638                state,
639                pool_audit_read_only_middleware,
640            ))
641            .layer(middleware::from_fn(status_metrics::record_http_status));
642
643        if let Some(cors) = self.cors {
644            app = app.layer(cors);
645        }
646
647        let make_service = app.into_make_service_with_connect_info::<std::net::SocketAddr>();
648        serve_with_connection_limits(listener, make_service, shutdown).await?;
649
650        Ok(local_addr.port())
651    }
652
653    pub fn addr(&self) -> &str {
654        &self.addr
655    }
656}
657
658async fn serve_with_connection_limits<M, S, F>(
659    listener: tokio::net::TcpListener,
660    mut make_service: M,
661    shutdown: F,
662) -> io::Result<()>
663where
664    M: Service<SocketAddr, Error = Infallible, Response = S> + Send + 'static,
665    M::Future: Send,
666    S: Service<Request<Body>, Response = Response, Error = Infallible> + Clone + Send + 'static,
667    S::Future: Send,
668    F: std::future::Future<Output = ()> + Send + 'static,
669{
670    let (signal_tx, signal_rx) = watch::channel(());
671    let signal_tx = Arc::new(signal_tx);
672    tokio::spawn(async move {
673        shutdown.await;
674        trace!("received graceful shutdown signal; stopping daemon listener");
675        drop(signal_rx);
676    });
677
678    let (close_tx, close_rx) = watch::channel(());
679
680    loop {
681        let (tcp_stream, remote_addr) = tokio::select! {
682            accepted = accept_tcp(&listener) => {
683                match accepted? {
684                    Some(connection) => connection,
685                    None => continue,
686                }
687            }
688            _ = signal_tx.closed() => {
689                trace!("shutdown signal received; no longer accepting daemon connections");
690                break;
691            }
692        };
693
694        configure_tcp_stream(&tcp_stream);
695        let tcp_stream = TokioIo::new(tcp_stream);
696
697        poll_fn(|cx| make_service.poll_ready(cx))
698            .await
699            .unwrap_or_else(|err| match err {});
700
701        let tower_service = make_service
702            .call(remote_addr)
703            .await
704            .unwrap_or_else(|err| match err {})
705            .map_request(|req: Request<Incoming>| req.map(Body::new));
706        let hyper_service = TowerToHyperService::new(tower_service);
707
708        let signal_tx = Arc::clone(&signal_tx);
709        let close_rx = close_rx.clone();
710
711        tokio::spawn(async move {
712            let mut builder = HyperBuilder::new(TokioExecutor::new());
713            builder
714                .http1()
715                .timer(TokioTimer::new())
716                .header_read_timeout(HTTP1_HEADER_READ_TIMEOUT);
717            builder
718                .http2()
719                .timer(TokioTimer::new())
720                .keep_alive_interval(Some(HTTP2_KEEPALIVE_INTERVAL))
721                .keep_alive_timeout(HTTP2_KEEPALIVE_TIMEOUT);
722
723            let conn = builder.serve_connection_with_upgrades(tcp_stream, hyper_service);
724            pin_mut!(conn);
725
726            let signal_closed = signal_tx.closed().fuse();
727            pin_mut!(signal_closed);
728
729            loop {
730                tokio::select! {
731                    result = conn.as_mut() => {
732                        if let Err(err) = result {
733                            trace!("daemon connection closed with error: {err:#}");
734                        }
735                        break;
736                    }
737                    _ = &mut signal_closed => {
738                        trace!("shutdown signal received by connection task");
739                        conn.as_mut().graceful_shutdown();
740                    }
741                }
742            }
743
744            drop(close_rx);
745        });
746    }
747
748    drop(close_rx);
749    drop(listener);
750    close_tx.closed().await;
751
752    Ok(())
753}
754
755fn configure_tcp_stream(tcp_stream: &tokio::net::TcpStream) {
756    if let Err(err) = tcp_stream.set_nodelay(true) {
757        debug!("failed to set TCP_NODELAY on daemon connection: {err:#}");
758    }
759
760    let socket = SockRef::from(tcp_stream);
761    if let Err(err) = socket.set_tcp_keepalive(
762        &TcpKeepalive::new()
763            .with_time(TCP_KEEPALIVE_TIME)
764            .with_interval(TCP_KEEPALIVE_INTERVAL),
765    ) {
766        debug!("failed to set TCP keepalive on daemon connection: {err:#}");
767    }
768}
769
770async fn accept_tcp(
771    listener: &tokio::net::TcpListener,
772) -> io::Result<Option<(tokio::net::TcpStream, SocketAddr)>> {
773    match listener.accept().await {
774        Ok(connection) => Ok(Some(connection)),
775        Err(err) => {
776            if is_connection_error(&err) {
777                return Ok(None);
778            }
779            if is_resource_exhaustion_error(&err) {
780                error!(
781                    "daemon accept failed due to file descriptor exhaustion; exiting for supervisor restart: {err}"
782                );
783                return Err(err);
784            }
785            error!("daemon accept error: {err}");
786            tokio::time::sleep(Duration::from_secs(1)).await;
787            Ok(None)
788        }
789    }
790}
791
792fn is_resource_exhaustion_error(err: &io::Error) -> bool {
793    matches!(
794        err.raw_os_error(),
795        Some(code) if code == libc::EMFILE || code == libc::ENFILE
796    )
797}
798
799fn is_connection_error(err: &io::Error) -> bool {
800    matches!(
801        err.kind(),
802        io::ErrorKind::ConnectionRefused
803            | io::ErrorKind::ConnectionAborted
804            | io::ErrorKind::ConnectionReset
805    )
806}
807
808fn current_unix_secs() -> u64 {
809    std::time::SystemTime::now()
810        .duration_since(std::time::UNIX_EPOCH)
811        .unwrap_or(std::time::Duration::ZERO)
812        .as_secs()
813}
814
815#[cfg(test)]
816mod tests {
817    use super::*;
818    use crate::nostr_relay::{NostrRelay, NostrRelayConfig};
819    use crate::storage::HashtreeStore;
820    use async_trait::async_trait;
821    use hashtree_config::StorageBackend;
822    use hashtree_core::types::Hash;
823    use hashtree_core::{
824        from_hex, nhash_encode, nhash_encode_full, sha256, to_hex, DirEntry, HashTree,
825        HashTreeConfig, LinkType, NHashData,
826    };
827    use nostr::{nips::nip19::ToBech32, EventBuilder, Keys, Kind, Timestamp};
828    use nostr_pubsub::{
829        EventBus, EventSource, PublishReport, PubsubProvider, PubsubProviderMode, QueryEvent,
830        QueryOptions, QueryReport, VerifiedEvent,
831    };
832    use serde_json::json;
833    use std::path::{Path, PathBuf};
834    use std::process::Command;
835    use std::sync::atomic::{AtomicUsize, Ordering};
836    use tempfile::TempDir;
837    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
838    use walkdir::WalkDir;
839
840    const AUDIT_SERVER_CHILD_DATA_ENV: &str = "HASHTREE_AUDIT_SERVER_CHILD_DATA";
841    const AUDIT_SERVER_CHILD_HASH_ENV: &str = "HASHTREE_AUDIT_SERVER_CHILD_HASH";
842
843    fn pool_data_file_snapshot(root: &Path) -> Vec<(PathBuf, Hash, std::time::SystemTime)> {
844        let mut snapshot = WalkDir::new(root)
845            .into_iter()
846            .filter_map(Result::ok)
847            .filter(|entry| {
848                if !entry.file_type().is_file() || entry.file_name() != "data.mdb" {
849                    return false;
850                }
851                matches!(
852                    entry
853                        .path()
854                        .parent()
855                        .and_then(Path::file_name)
856                        .and_then(|name| name.to_str()),
857                    Some(hashtree_lmdb::SHARED_BLOB_POOL_DIR_NAME) | Some("blobs")
858                )
859            })
860            .map(|entry| {
861                let path = entry.into_path();
862                let bytes = std::fs::read(&path).expect("read Pool LMDB data file");
863                let modified = std::fs::metadata(&path)
864                    .expect("read Pool LMDB metadata")
865                    .modified()
866                    .expect("read Pool LMDB mtime");
867                (path, sha256(&bytes), modified)
868            })
869            .collect::<Vec<_>>();
870        snapshot.sort_unstable_by(|left, right| left.0.cmp(&right.0));
871        snapshot
872    }
873
874    async fn assert_pool_audit_maintenance_response(response: reqwest::Response) -> Result<()> {
875        assert_eq!(response.status(), reqwest::StatusCode::SERVICE_UNAVAILABLE);
876        assert_eq!(
877            response
878                .headers()
879                .get(POOL_AUDIT_READ_ONLY_REASON_HEADER)
880                .and_then(|value| value.to_str().ok()),
881            Some(POOL_AUDIT_READ_ONLY_REASON)
882        );
883        let body: serde_json::Value = response.json().await?;
884        assert_eq!(
885            body,
886            json!({
887                "error": POOL_AUDIT_READ_ONLY_HTTP_ERROR,
888            })
889        );
890        Ok(())
891    }
892
893    struct StaticProvider {
894        event: Option<VerifiedEvent>,
895        queries: AtomicUsize,
896        publishes: AtomicUsize,
897        mode: PubsubProviderMode,
898    }
899
900    #[test]
901    fn pool_audit_request_gate_only_allows_read_ingress() {
902        for method in [Method::GET, Method::HEAD, Method::OPTIONS] {
903            assert!(pool_audit_request_is_allowed(&method, "/health"));
904        }
905        assert!(pool_audit_request_is_allowed(&Method::POST, "/blob/batch"));
906        assert!(pool_audit_request_is_allowed(
907            &Method::POST,
908            "/upload/check"
909        ));
910        for (method, path) in [
911            (Method::GET, "/ws"),
912            (Method::GET, "/ws/"),
913            (Method::PUT, "/upload"),
914            (Method::POST, "/upload/batch"),
915            (Method::POST, "/upload/batch-binary"),
916            (Method::POST, "/api/pin/hash"),
917            (Method::POST, "/api/nostr/events"),
918            (Method::DELETE, "/hash"),
919        ] {
920            assert!(
921                !pool_audit_request_is_allowed(&method, path),
922                "{method} {path} must be blocked"
923            );
924        }
925    }
926
927    #[tokio::test]
928    #[ignore = "subprocess entry point for Pool audit-serving HTTP verification"]
929    async fn pool_audit_read_only_server_subprocess() -> Result<()> {
930        let Some(data_dir) = std::env::var_os(AUDIT_SERVER_CHILD_DATA_ENV) else {
931            return Ok(());
932        };
933        let hash_hex = std::env::var(AUDIT_SERVER_CHILD_HASH_ENV)?;
934        let store = Arc::new(HashtreeStore::new_with_backend(
935            data_dir,
936            StorageBackend::Lmdb,
937            16 * 1024 * 1024 * 1024,
938        )?);
939        assert!(store.is_pool_audit_read_only());
940
941        let owner = Keys::generate();
942        let tree_name = "pool-audit-external";
943        let root_event = EventBuilder::new(Kind::Custom(30064), "")
944            .tags(vec![
945                nostr::Tag::identifier(tree_name),
946                nostr::Tag::custom(nostr::TagKind::custom("l"), vec!["hashtree".to_string()]),
947                nostr::Tag::custom(nostr::TagKind::custom("hash"), vec![hash_hex.clone()]),
948            ])
949            .sign_with_keys(&owner)?;
950        let provider = Arc::new(StaticProvider {
951            event: Some(VerifiedEvent::try_from(root_event.clone())?),
952            queries: AtomicUsize::new(0),
953            publishes: AtomicUsize::new(0),
954            mode: PubsubProviderMode::LocalOnly,
955        });
956        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
957        let port = listener.local_addr()?.port();
958        let server = HashtreeServer::new(Arc::clone(&store), "127.0.0.1:0".to_string())
959            .with_nostr_provider(provider.clone());
960        let handle =
961            tokio::spawn(async move { server.run_with_listener(listener).await.map(|_| ()) });
962        let client = reqwest::Client::new();
963        let base = format!("http://127.0.0.1:{port}");
964
965        let response = client.get(format!("{base}/{hash_hex}")).send().await?;
966        assert_eq!(response.status(), reqwest::StatusCode::OK);
967        assert_eq!(response.bytes().await?.as_ref(), b"audit-serving bytes");
968
969        let status: serde_json::Value = client
970            .get(format!("{base}/api/status"))
971            .send()
972            .await?
973            .error_for_status()?
974            .json()
975            .await?;
976        assert_eq!(status["pool_audit_read_only"], true);
977        assert_eq!(status["capabilities"]["writes"], false);
978
979        let npub = owner.public_key().to_bech32()?;
980        let resolved: serde_json::Value = client
981            .get(format!(
982                "{base}/api/nostr/resolve/{npub}/{tree_name}?refresh=1"
983            ))
984            .send()
985            .await?
986            .error_for_status()?
987            .json()
988            .await?;
989        assert_eq!(resolved["hash"], hash_hex);
990        assert_eq!(resolved["source"], "fips-pubsub");
991        assert_eq!(resolved["event_id"], root_event.id.to_hex());
992        assert_eq!(provider.queries.load(Ordering::Relaxed), 1);
993        assert_eq!(provider.publishes.load(Ordering::Relaxed), 0);
994
995        assert_pool_audit_maintenance_response(
996            client
997                .post(format!("{base}/api/nostr/events"))
998                .json(&root_event)
999                .send()
1000                .await?,
1001        )
1002        .await?;
1003        assert_eq!(provider.publishes.load(Ordering::Relaxed), 0);
1004
1005        assert_pool_audit_maintenance_response(
1006            client
1007                .put(format!("{base}/upload"))
1008                .body(b"blocked upload".to_vec())
1009                .send()
1010                .await?,
1011        )
1012        .await?;
1013        assert_pool_audit_maintenance_response(
1014            client
1015                .post(format!("{base}/upload/batch"))
1016                .json(&json!({"blobs": []}))
1017                .send()
1018                .await?,
1019        )
1020        .await?;
1021        assert_pool_audit_maintenance_response(
1022            client.delete(format!("{base}/{hash_hex}")).send().await?,
1023        )
1024        .await?;
1025        assert_pool_audit_maintenance_response(client.get(format!("{base}/ws")).send().await?)
1026            .await?;
1027
1028        let response = client.get(format!("{base}/{hash_hex}")).send().await?;
1029        assert_eq!(response.status(), reqwest::StatusCode::OK);
1030        assert_eq!(response.bytes().await?.as_ref(), b"audit-serving bytes");
1031
1032        handle.abort();
1033        Ok(())
1034    }
1035
1036    #[test]
1037    fn pool_audit_read_only_server_resolves_external_nostr_without_mutation() -> Result<()> {
1038        let temp = TempDir::new()?;
1039        let data_dir = temp.path().join("data");
1040        let store = HashtreeStore::new_with_backend(
1041            &data_dir,
1042            StorageBackend::Lmdb,
1043            16 * 1024 * 1024 * 1024,
1044        )?;
1045        let hash_hex = store.put_blob(b"audit-serving bytes")?;
1046        store.force_sync()?;
1047        drop(store);
1048
1049        let before = pool_data_file_snapshot(&data_dir);
1050        assert!(
1051            before.len() >= 2,
1052            "expected Pool catalog and at least one member data file"
1053        );
1054
1055        let output = Command::new(std::env::current_exe()?)
1056            .arg("--ignored")
1057            .arg("--exact")
1058            .arg("server::tests::pool_audit_read_only_server_subprocess")
1059            .arg("--nocapture")
1060            .env(AUDIT_SERVER_CHILD_DATA_ENV, &data_dir)
1061            .env(AUDIT_SERVER_CHILD_HASH_ENV, &hash_hex)
1062            .env(hashtree_lmdb::POOL_AUDIT_READ_ONLY_ENV, "1")
1063            .env_remove("HTREE_LMDB_HOT_BLOB_DIR")
1064            .env_remove("HTREE_LMDB_HOT_BLOB_LEGACY_DIR")
1065            .env_remove("HTREE_LMDB_HOT_EXTERNAL_BLOB_DIR")
1066            .env_remove("HTREE_LMDB_LEGACY_EXTERNAL_BLOB_DIR")
1067            .env("RUST_TEST_THREADS", "1")
1068            .output()?;
1069        assert!(
1070            output.status.success(),
1071            "audit-serving subprocess failed: stdout={} stderr={}",
1072            String::from_utf8_lossy(&output.stdout),
1073            String::from_utf8_lossy(&output.stderr)
1074        );
1075
1076        let after = pool_data_file_snapshot(&data_dir);
1077        assert_eq!(
1078            after, before,
1079            "audit-serving process mutated Pool data files"
1080        );
1081        Ok(())
1082    }
1083
1084    #[async_trait]
1085    impl EventBus for StaticProvider {
1086        async fn publish(
1087            &self,
1088            _event: VerifiedEvent,
1089            _source: EventSource,
1090        ) -> nostr_pubsub::Result<PublishReport> {
1091            self.publishes.fetch_add(1, Ordering::Relaxed);
1092            Ok(PublishReport {
1093                accepted: true,
1094                priority: 0,
1095                reason: None,
1096            })
1097        }
1098
1099        async fn query(
1100            &self,
1101            _filters: Vec<nostr_pubsub::Filter>,
1102            _options: QueryOptions,
1103        ) -> nostr_pubsub::Result<QueryReport> {
1104            self.queries.fetch_add(1, Ordering::Relaxed);
1105            Ok(QueryReport {
1106                events: self
1107                    .event
1108                    .clone()
1109                    .map(|event| QueryEvent {
1110                        event,
1111                        source: EventSource::fips_endpoint("browser-router"),
1112                        priority: 0,
1113                    })
1114                    .into_iter()
1115                    .collect(),
1116            })
1117        }
1118    }
1119
1120    impl PubsubProvider for StaticProvider {
1121        fn mode(&self) -> PubsubProviderMode {
1122            self.mode
1123        }
1124    }
1125
1126    #[test]
1127    fn resource_exhaustion_errors_are_fatal_accept_errors() {
1128        assert!(is_resource_exhaustion_error(&io::Error::from_raw_os_error(
1129            libc::EMFILE
1130        )));
1131        assert!(is_resource_exhaustion_error(&io::Error::from_raw_os_error(
1132            libc::ENFILE
1133        )));
1134        assert!(!is_resource_exhaustion_error(
1135            &io::Error::from_raw_os_error(libc::ECONNRESET)
1136        ));
1137    }
1138
1139    #[test]
1140    fn upload_queue_semaphores_fit_all_tokio_targets() -> Result<()> {
1141        let temp_dir = TempDir::new()?;
1142        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1143        let server = HashtreeServer::new(store, "127.0.0.1:0".to_string());
1144
1145        assert_eq!(
1146            server.state.optimistic_upload_queue.available_permits(),
1147            256 * 1024 * 1024
1148        );
1149        assert_eq!(
1150            server
1151                .state
1152                .blossom_upload_replica_queue
1153                .available_permits(),
1154            256 * 1024 * 1024
1155        );
1156        assert_eq!(
1157            bounded_upload_queue_bytes(u64::MAX),
1158            tokio::sync::Semaphore::MAX_PERMITS
1159        );
1160        Ok(())
1161    }
1162
1163    #[test]
1164    fn server_builder_seeds_initial_tree_roots() -> Result<()> {
1165        let temp_dir = TempDir::new()?;
1166        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1167        let hash = from_hex("1111111111111111111111111111111111111111111111111111111111111111")?;
1168        let key = from_hex("2222222222222222222222222222222222222222222222222222222222222222")?;
1169        let cid = Cid {
1170            hash,
1171            key: Some(key),
1172        };
1173
1174        let server = HashtreeServer::new(store, "127.0.0.1:0".to_string())
1175            .with_cached_tree_roots(vec![("npub1example/sites".to_string(), cid.clone())]);
1176        let cached = server
1177            .state
1178            .tree_root_cache
1179            .lock()
1180            .unwrap()
1181            .get("npub1example/sites")
1182            .cloned()
1183            .expect("seeded root");
1184
1185        assert_eq!(cached.cid, cid);
1186        assert_eq!(cached.source, "embedded-bootstrap");
1187        assert!(cached.root_event.is_none());
1188        Ok(())
1189    }
1190
1191    #[test]
1192    fn iris_localhost_hosts_map_to_existing_tree_routes() {
1193        assert_eq!(
1194            resolve_virtual_tree_host("NHASH1EXAMPLE.iris.localhost:8080"),
1195            Some("/htree/nhash1example".to_string())
1196        );
1197        assert_eq!(
1198            resolve_virtual_tree_host("audio.NPUB1EXAMPLE.iris.localhost:8080"),
1199            Some("/htree/npub1example/audio".to_string())
1200        );
1201        assert_eq!(
1202            resolve_virtual_tree_host("audio.extra.npub1example.iris.localhost"),
1203            None
1204        );
1205        assert_eq!(
1206            resolve_virtual_tree_host("nhash1example.htree.localhost"),
1207            None
1208        );
1209    }
1210
1211    #[tokio::test]
1212    async fn test_server_serve_file() -> Result<()> {
1213        let temp_dir = TempDir::new()?;
1214        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1215
1216        // Create and upload a test file
1217        let test_file = temp_dir.path().join("test.txt");
1218        std::fs::write(&test_file, b"Hello, Hashtree!")?;
1219
1220        let cid = store.upload_file(&test_file)?;
1221        let hash = from_hex(&cid)?;
1222
1223        // Verify we can get it
1224        let content = store.get_file(&hash)?;
1225        assert!(content.is_some());
1226        assert_eq!(content.unwrap(), b"Hello, Hashtree!");
1227
1228        Ok(())
1229    }
1230
1231    #[tokio::test]
1232    async fn test_server_list_pins() -> Result<()> {
1233        let temp_dir = TempDir::new()?;
1234        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1235
1236        let test_file = temp_dir.path().join("test.txt");
1237        std::fs::write(&test_file, b"Test")?;
1238
1239        let cid = store.upload_file(&test_file)?;
1240        let hash = from_hex(&cid)?;
1241
1242        let pins = store.list_pins_raw()?;
1243        assert_eq!(pins.len(), 1);
1244        assert_eq!(pins[0], hash);
1245
1246        Ok(())
1247    }
1248
1249    async fn spawn_test_server(
1250        store: Arc<HashtreeStore>,
1251    ) -> Result<(u16, tokio::task::JoinHandle<Result<()>>)> {
1252        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1253        let port = listener.local_addr()?.port();
1254        let server = HashtreeServer::new(store, "127.0.0.1:0".to_string());
1255        let handle =
1256            tokio::spawn(async move { server.run_with_listener(listener).await.map(|_| ()) });
1257        Ok((port, handle))
1258    }
1259
1260    pub(super) async fn spawn_test_server_with_auth(
1261        store: Arc<HashtreeStore>,
1262    ) -> Result<(u16, tokio::task::JoinHandle<Result<()>>)> {
1263        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1264        let port = listener.local_addr()?.port();
1265        let server = HashtreeServer::new(store, "127.0.0.1:0".to_string())
1266            .with_auth("test-user".to_string(), "test-password".to_string());
1267        let handle =
1268            tokio::spawn(async move { server.run_with_listener(listener).await.map(|_| ()) });
1269        Ok((port, handle))
1270    }
1271
1272    #[tokio::test]
1273    async fn multipart_upload_rejects_path_traversal_filenames() -> Result<()> {
1274        let temp_dir = TempDir::new()?;
1275        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1276        let target = temp_dir.path().join("must-not-overwrite.txt");
1277        std::fs::write(&target, b"original contents")?;
1278        let (port, handle) = spawn_test_server(Arc::clone(&store)).await?;
1279        let client = reqwest::Client::new();
1280        let relative_target = format!(
1281            "../{}/must-not-overwrite.txt",
1282            temp_dir.path().file_name().unwrap().to_string_lossy()
1283        );
1284
1285        for filename in [
1286            target.to_string_lossy().as_ref(),
1287            &relative_target,
1288            r"..\must-not-overwrite.txt",
1289            r"C:\must-not-overwrite.txt",
1290            "nested/file.txt",
1291            "trailing-slash/",
1292            ".",
1293            "..",
1294            "",
1295        ] {
1296            let body = format!(
1297                "--upload-boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\nContent-Type: application/octet-stream\r\n\r\nattacker contents\r\n--upload-boundary--\r\n"
1298            );
1299            let response = client
1300                .post(format!("http://127.0.0.1:{port}/upload"))
1301                .header(
1302                    reqwest::header::CONTENT_TYPE,
1303                    "multipart/form-data; boundary=upload-boundary",
1304                )
1305                .body(body)
1306                .send()
1307                .await?;
1308
1309            assert_eq!(
1310                std::fs::read(&target)?,
1311                b"original contents",
1312                "multipart uploads must not overwrite files outside their temporary directory"
1313            );
1314            assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1315        }
1316
1317        let response = client
1318            .post(format!("http://127.0.0.1:{port}/upload"))
1319            .header(
1320                reqwest::header::CONTENT_TYPE,
1321                "multipart/form-data; boundary=upload-boundary",
1322            )
1323            .body("--upload-boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"safe.txt\"\r\nContent-Type: application/octet-stream\r\n\r\nvalid upload\r\n--upload-boundary--\r\n")
1324            .send()
1325            .await?;
1326        assert_eq!(response.status(), StatusCode::OK);
1327        let payload: serde_json::Value = response.json().await?;
1328        let hash = from_hex(payload["cid"].as_str().expect("uploaded CID"))?;
1329        assert_eq!(store.get_file(&hash)?, Some(b"valid upload".to_vec()));
1330
1331        handle.abort();
1332        Ok(())
1333    }
1334
1335    async fn encrypted_test_directory(store: &HashtreeStore) -> Result<(Cid, Cid, Vec<u8>)> {
1336        let tree = HashTree::new(
1337            HashTreeConfig::new(store.store_arc())
1338                .with_chunk_size(4)
1339                .with_max_links(2),
1340        );
1341        let content = b"encrypted descendant content spanning chunks".to_vec();
1342        let (file_cid, file_size) = tree.put(&content).await?;
1343        let nested_cid = tree
1344            .put_directory(vec![DirEntry::from_cid("post.json", &file_cid)
1345                .with_size(file_size)
1346                .with_link_type(LinkType::File)])
1347            .await?;
1348        let root_cid = tree
1349            .put_directory(vec![
1350                DirEntry::from_cid("events", &nested_cid).with_link_type(LinkType::Dir)
1351            ])
1352            .await?;
1353        Ok((root_cid, file_cid, content))
1354    }
1355
1356    async fn spawn_test_server_with_nostr_relay(
1357        store: Arc<HashtreeStore>,
1358        relay: Arc<NostrRelay>,
1359    ) -> Result<(u16, tokio::task::JoinHandle<Result<()>>)> {
1360        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1361        let port = listener.local_addr()?.port();
1362        let server = HashtreeServer::new(store, "127.0.0.1:0".to_string()).with_nostr_relay(relay);
1363        let handle =
1364            tokio::spawn(async move { server.run_with_listener(listener).await.map(|_| ()) });
1365        Ok((port, handle))
1366    }
1367
1368    #[tokio::test]
1369    async fn unauthenticated_native_store_mutation_is_rejected() -> Result<()> {
1370        let temp_dir = TempDir::new()?;
1371        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1372        let body = b"unauthorized native store write";
1373        let hash = sha256(body);
1374        let hash_hex = to_hex(&hash);
1375        let (port, handle) = spawn_test_server(Arc::clone(&store)).await?;
1376
1377        let response = reqwest::Client::new()
1378            .put(format!("http://127.0.0.1:{port}/__iris/store/{hash_hex}"))
1379            .body(body.to_vec())
1380            .send()
1381            .await?;
1382
1383        assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN);
1384        assert!(store.get_blob(&hash)?.is_none());
1385        handle.abort();
1386        Ok(())
1387    }
1388
1389    #[tokio::test]
1390    async fn unauthenticated_cache_tree_root_is_rejected() -> Result<()> {
1391        let temp_dir = TempDir::new()?;
1392        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1393        let (port, handle) = spawn_test_server(store).await?;
1394
1395        let response = reqwest::Client::new()
1396            .post(format!("http://127.0.0.1:{port}/api/cache-tree-root"))
1397            .json(&json!({
1398                "npub": "npub1example",
1399                "treeName": "video",
1400                "hash": "988db3f24dc222715f1c1e1fa5876690d3147122243d72d85fd44283867cd61a",
1401                "visibility": "public"
1402            }))
1403            .send()
1404            .await?;
1405
1406        assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN);
1407        handle.abort();
1408        Ok(())
1409    }
1410
1411    #[tokio::test]
1412    async fn pin_tree_requires_configured_valid_auth() -> Result<()> {
1413        let temp_dir = TempDir::new()?;
1414        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1415        let nhash = nhash_encode(&sha256(b"missing"))?;
1416        let client = reqwest::Client::new();
1417
1418        let (port, handle) = spawn_test_server(Arc::clone(&store)).await?;
1419        let response = client
1420            .post(format!("http://127.0.0.1:{port}/api/pin-tree"))
1421            .json(&json!({"nhash": nhash}))
1422            .send()
1423            .await?;
1424        assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN);
1425        handle.abort();
1426
1427        let (port, handle) = spawn_test_server_with_auth(store).await?;
1428        let response = client
1429            .post(format!("http://127.0.0.1:{port}/api/pin-tree"))
1430            .json(&json!({"nhash": nhash}))
1431            .send()
1432            .await?;
1433        assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
1434        handle.abort();
1435        Ok(())
1436    }
1437
1438    #[tokio::test]
1439    async fn pin_tree_rejects_malformed_noncanonical_and_missing_roots() -> Result<()> {
1440        let temp_dir = TempDir::new()?;
1441        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1442        let missing_hash = sha256(b"not stored");
1443        let canonical = nhash_encode(&missing_hash)?;
1444        let (port, handle) = spawn_test_server_with_auth(Arc::clone(&store)).await?;
1445        let client = reqwest::Client::new();
1446
1447        for nhash in ["not-an-nhash".to_string(), format!("hashtree:{canonical}")] {
1448            let response = client
1449                .post(format!("http://127.0.0.1:{port}/api/pin-tree"))
1450                .basic_auth("test-user", Some("test-password"))
1451                .json(&json!({"nhash": nhash}))
1452                .send()
1453                .await?;
1454            assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
1455        }
1456
1457        let response = client
1458            .post(format!("http://127.0.0.1:{port}/api/pin-tree"))
1459            .basic_auth("test-user", Some("test-password"))
1460            .json(&json!({"nhash": canonical}))
1461            .send()
1462            .await?;
1463        assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND);
1464        assert!(!store.is_pinned(&missing_hash)?);
1465        assert!(store.get_tree_meta(&missing_hash)?.is_none());
1466        handle.abort();
1467        Ok(())
1468    }
1469
1470    #[tokio::test]
1471    async fn pin_tree_indexes_encrypted_descendants_and_is_idempotent() -> Result<()> {
1472        let temp_dir = TempDir::new()?;
1473        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1474        let (root_cid, file_cid, expected_content) = encrypted_test_directory(&store).await?;
1475        let nhash = nhash_encode_full(&NHashData {
1476            hash: root_cid.hash,
1477            decrypt_key: root_cid.key,
1478        })?;
1479        let (port, handle) = spawn_test_server_with_auth(Arc::clone(&store)).await?;
1480        let client = reqwest::Client::new();
1481
1482        for expected_already_pinned in [false, true] {
1483            let response = client
1484                .post(format!("http://127.0.0.1:{port}/api/pin-tree"))
1485                .basic_auth("test-user", Some("test-password"))
1486                .json(&json!({"nhash": nhash}))
1487                .send()
1488                .await?;
1489            assert_eq!(response.status(), reqwest::StatusCode::OK);
1490            let body: serde_json::Value = response.json().await?;
1491            assert_eq!(body["already_pinned"], expected_already_pinned);
1492            assert!(body["indexed_hashes"].as_u64().unwrap_or_default() > 2);
1493        }
1494
1495        assert!(store.is_pinned(&root_cid.hash)?);
1496        assert_eq!(store.list_pins_raw()?, vec![root_cid.hash]);
1497        assert_eq!(store.list_indexed_trees()?.len(), 1);
1498        let tree = HashTree::new(HashTreeConfig::new(store.store_arc()));
1499        assert_eq!(
1500            tree.get(&file_cid, None).await?,
1501            Some(expected_content),
1502            "encrypted descendant remains readable"
1503        );
1504        handle.abort();
1505        Ok(())
1506    }
1507
1508    #[tokio::test]
1509    async fn pin_tree_missing_descendant_leaves_no_pin_or_index() -> Result<()> {
1510        let temp_dir = TempDir::new()?;
1511        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1512        let (root_cid, file_cid, _) = encrypted_test_directory(&store).await?;
1513        assert!(store.router().delete_sync(&file_cid.hash)?);
1514        let nhash = nhash_encode_full(&NHashData {
1515            hash: root_cid.hash,
1516            decrypt_key: root_cid.key,
1517        })?;
1518        let (port, handle) = spawn_test_server_with_auth(Arc::clone(&store)).await?;
1519
1520        let response = reqwest::Client::new()
1521            .post(format!("http://127.0.0.1:{port}/api/pin-tree"))
1522            .basic_auth("test-user", Some("test-password"))
1523            .json(&json!({"nhash": nhash}))
1524            .send()
1525            .await?;
1526
1527        assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
1528        assert!(!store.is_pinned(&root_cid.hash)?);
1529        assert!(store.get_tree_meta(&root_cid.hash)?.is_none());
1530        handle.abort();
1531        Ok(())
1532    }
1533
1534    #[tokio::test]
1535    async fn pin_tree_resource_failure_leaves_no_pin_or_index() -> Result<()> {
1536        let temp_dir = TempDir::new()?;
1537        let store = Arc::new(HashtreeStore::with_options(
1538            temp_dir.path().join("db"),
1539            None,
1540            8,
1541        )?);
1542        let tree = HashTree::new(HashTreeConfig::new(store.store_arc()));
1543        let (cid, _) = tree.put(b"larger than the pin tree byte budget").await?;
1544        let nhash = nhash_encode_full(&NHashData {
1545            hash: cid.hash,
1546            decrypt_key: cid.key,
1547        })?;
1548        let (port, handle) = spawn_test_server_with_auth(Arc::clone(&store)).await?;
1549
1550        let response = reqwest::Client::new()
1551            .post(format!("http://127.0.0.1:{port}/api/pin-tree"))
1552            .basic_auth("test-user", Some("test-password"))
1553            .json(&json!({"nhash": nhash}))
1554            .send()
1555            .await?;
1556
1557        assert_eq!(response.status(), reqwest::StatusCode::PAYLOAD_TOO_LARGE);
1558        assert!(!store.is_pinned(&cid.hash)?);
1559        assert!(store.get_tree_meta(&cid.hash)?.is_none());
1560        handle.abort();
1561        Ok(())
1562    }
1563
1564    #[tokio::test]
1565    async fn unauthenticated_upload_batch_is_rejected_before_json_extraction() -> Result<()> {
1566        let temp_dir = TempDir::new()?;
1567        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1568        let (port, handle) = spawn_test_server(store).await?;
1569
1570        let response = reqwest::Client::new()
1571            .post(format!("http://127.0.0.1:{port}/upload/batch"))
1572            .header(reqwest::header::CONTENT_TYPE, "application/json")
1573            .body("not valid json")
1574            .send()
1575            .await?;
1576
1577        assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
1578        handle.abort();
1579        Ok(())
1580    }
1581
1582    #[tokio::test]
1583    async fn unauthenticated_single_upload_is_rejected_before_body_limit() -> Result<()> {
1584        let temp_dir = TempDir::new()?;
1585        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1586        let (port, handle) = spawn_test_server(store).await?;
1587        let declared_bytes = blossom::MAX_SINGLE_UPLOAD_BODY_BYTES + 1;
1588        let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await?;
1589        stream
1590            .write_all(
1591                format!(
1592                    "PUT /upload HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nContent-Length: {declared_bytes}\r\nConnection: close\r\n\r\n"
1593                )
1594                .as_bytes(),
1595            )
1596            .await?;
1597
1598        // Send headers only: receiving a response proves authentication ran
1599        // before either buffering the declared body or enforcing its limit.
1600        let mut status_line = String::new();
1601        BufReader::new(stream).read_line(&mut status_line).await?;
1602        assert_eq!(status_line.trim_end(), "HTTP/1.1 401 Unauthorized");
1603        handle.abort();
1604        Ok(())
1605    }
1606
1607    #[tokio::test]
1608    async fn upload_options_preflight_does_not_require_auth() -> Result<()> {
1609        let temp_dir = TempDir::new()?;
1610        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1611        let (port, handle) = spawn_test_server(store).await?;
1612
1613        let response = reqwest::Client::new()
1614            .request(
1615                reqwest::Method::OPTIONS,
1616                format!("http://127.0.0.1:{port}/upload"),
1617            )
1618            .send()
1619            .await?;
1620
1621        assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
1622        handle.abort();
1623        Ok(())
1624    }
1625
1626    #[tokio::test]
1627    async fn virtual_tree_hosts_serve_root_assets_and_spa_fallbacks() -> Result<()> {
1628        clear_virtual_tree_hosts_for_test();
1629
1630        let temp_dir = TempDir::new()?;
1631        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1632        let tree = HashTree::new(HashTreeConfig::new(store.store_arc()).public());
1633
1634        let (index_cid, _) = tree
1635            .put(b"<!doctype html><title>Virtual host ok</title>")
1636            .await?;
1637        let (favicon_cid, _) = tree.put(b"ico").await?;
1638        let (main_js_cid, _) = tree.put(b"console.log('ok');").await?;
1639        let assets_dir = tree
1640            .put_directory(vec![
1641                DirEntry::from_cid("main.js", &main_js_cid).with_link_type(LinkType::File)
1642            ])
1643            .await?;
1644        let root_cid = tree
1645            .put_directory(vec![
1646                DirEntry::from_cid("index.html", &index_cid).with_link_type(LinkType::File),
1647                DirEntry::from_cid("favicon.ico", &favicon_cid).with_link_type(LinkType::File),
1648                DirEntry::from_cid("assets", &assets_dir).with_link_type(LinkType::Dir),
1649            ])
1650            .await?;
1651        let nhash = nhash_encode(&root_cid.hash)?;
1652        let host = "tree-test.htree.localhost";
1653        register_virtual_tree_host(host, &format!("/htree/{nhash}"));
1654
1655        let (port, handle) = spawn_test_server(store).await?;
1656        let base_url = format!("http://127.0.0.1:{port}");
1657        let host_header = format!("{host}:{port}");
1658        let client = reqwest::Client::new();
1659
1660        let root_response = client
1661            .get(format!("{base_url}/"))
1662            .header("Host", &host_header)
1663            .header("Accept", "text/html")
1664            .send()
1665            .await?;
1666        assert_eq!(root_response.status(), reqwest::StatusCode::OK);
1667        assert_eq!(
1668            root_response.bytes().await?.as_ref(),
1669            b"<!doctype html><title>Virtual host ok</title>"
1670        );
1671
1672        let favicon_response = client
1673            .get(format!("{base_url}/favicon.ico"))
1674            .header("Host", &host_header)
1675            .send()
1676            .await?;
1677        assert_eq!(favicon_response.status(), reqwest::StatusCode::OK);
1678        assert_eq!(favicon_response.bytes().await?.as_ref(), b"ico");
1679
1680        let js_response = client
1681            .get(format!("{base_url}/assets/main.js"))
1682            .header("Host", &host_header)
1683            .send()
1684            .await?;
1685        assert_eq!(js_response.status(), reqwest::StatusCode::OK);
1686        assert_eq!(js_response.bytes().await?.as_ref(), b"console.log('ok');");
1687
1688        let profile_response = client
1689            .get(format!("{base_url}/users/npub1example"))
1690            .header("Host", &host_header)
1691            .header("Accept", "text/html")
1692            .send()
1693            .await?;
1694        assert_eq!(profile_response.status(), reqwest::StatusCode::OK);
1695        assert_eq!(
1696            profile_response.bytes().await?.as_ref(),
1697            b"<!doctype html><title>Virtual host ok</title>"
1698        );
1699
1700        handle.abort();
1701        clear_virtual_tree_hosts_for_test();
1702
1703        Ok(())
1704    }
1705
1706    #[tokio::test]
1707    async fn iris_localhost_hosts_serve_immutable_and_named_sites() -> Result<()> {
1708        let temp_dir = TempDir::new()?;
1709        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1710        let tree = HashTree::new(HashTreeConfig::new(store.store_arc()).public());
1711
1712        let (index_cid, _) = tree
1713            .put(b"<!doctype html><title>Iris localhost ok</title>")
1714            .await?;
1715        let (main_js_cid, _) = tree.put(b"console.log('iris localhost');").await?;
1716        let assets_dir = tree
1717            .put_directory(vec![
1718                DirEntry::from_cid("main.js", &main_js_cid).with_link_type(LinkType::File)
1719            ])
1720            .await?;
1721        let root_cid = tree
1722            .put_directory(vec![
1723                DirEntry::from_cid("index.html", &index_cid).with_link_type(LinkType::File),
1724                DirEntry::from_cid("assets", &assets_dir).with_link_type(LinkType::Dir),
1725            ])
1726            .await?;
1727
1728        let nhash = nhash_encode(&root_cid.hash)?;
1729        let npub = Keys::generate().public_key().to_bech32()?;
1730        let site = "audio";
1731        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1732        let port = listener.local_addr()?.port();
1733        let server = HashtreeServer::new(Arc::clone(&store), "127.0.0.1:0".to_string())
1734            .with_public_plaintext_reads(true)
1735            .with_cached_tree_roots(vec![(format!("{npub}/{site}"), root_cid)]);
1736        let handle =
1737            tokio::spawn(async move { server.run_with_listener(listener).await.map(|_| ()) });
1738        let client = reqwest::Client::new();
1739        let base_url = format!("http://127.0.0.1:{port}");
1740
1741        for host in [
1742            format!("{nhash}.iris.localhost:{port}"),
1743            format!("{site}.{npub}.iris.localhost:{port}"),
1744        ] {
1745            let root_response = client
1746                .get(format!("{base_url}/"))
1747                .header("Host", &host)
1748                .header("Accept", "text/html")
1749                .send()
1750                .await?;
1751            assert_eq!(root_response.status(), reqwest::StatusCode::OK, "{host}");
1752            assert_eq!(
1753                root_response.bytes().await?.as_ref(),
1754                b"<!doctype html><title>Iris localhost ok</title>"
1755            );
1756
1757            let asset_response = client
1758                .get(format!("{base_url}/assets/main.js"))
1759                .header("Host", &host)
1760                .send()
1761                .await?;
1762            assert_eq!(asset_response.status(), reqwest::StatusCode::OK, "{host}");
1763            assert_eq!(
1764                asset_response.bytes().await?.as_ref(),
1765                b"console.log('iris localhost');"
1766            );
1767        }
1768
1769        handle.abort();
1770        Ok(())
1771    }
1772
1773    #[tokio::test]
1774    async fn named_iris_site_resolves_from_fips_event_provider_without_relays() -> Result<()> {
1775        let temp_dir = TempDir::new()?;
1776        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1777        let tree = HashTree::new(HashTreeConfig::new(store.store_arc()).public());
1778        let (index_cid, _) = tree.put(b"fips event provider site").await?;
1779        let root_cid = tree
1780            .put_directory(vec![
1781                DirEntry::from_cid("index.html", &index_cid).with_link_type(LinkType::File)
1782            ])
1783            .await?;
1784        let owner = Keys::generate();
1785        let site = "radio";
1786        let root_event = EventBuilder::new(Kind::Custom(30064), "")
1787            .tags(vec![
1788                nostr::Tag::identifier(site),
1789                nostr::Tag::custom(nostr::TagKind::custom("l"), vec!["hashtree".to_string()]),
1790                nostr::Tag::custom(nostr::TagKind::custom("hash"), vec![to_hex(&root_cid.hash)]),
1791            ])
1792            .sign_with_keys(&owner)?;
1793        let provider = Arc::new(StaticProvider {
1794            event: Some(VerifiedEvent::try_from(root_event)?),
1795            queries: AtomicUsize::new(0),
1796            publishes: AtomicUsize::new(0),
1797            mode: PubsubProviderMode::LocalOnly,
1798        });
1799
1800        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1801        let port = listener.local_addr()?.port();
1802        let server = HashtreeServer::new(Arc::clone(&store), "127.0.0.1:0".to_string())
1803            .with_public_plaintext_reads(true)
1804            .with_nostr_provider(provider.clone());
1805        assert!(server.state.nostr_relay_urls.is_empty());
1806        assert_eq!(
1807            server.state.nostr_provider.as_ref().unwrap().mode(),
1808            PubsubProviderMode::LocalOnly
1809        );
1810        let handle =
1811            tokio::spawn(async move { server.run_with_listener(listener).await.map(|_| ()) });
1812
1813        let npub = owner.public_key().to_bech32()?;
1814        let response = reqwest::Client::new()
1815            .get(format!("http://127.0.0.1:{port}/"))
1816            .header("Host", format!("{site}.{npub}.iris.localhost"))
1817            .header("Accept", "text/html")
1818            .send()
1819            .await?;
1820
1821        assert_eq!(response.status(), reqwest::StatusCode::OK);
1822        assert_eq!(
1823            response.bytes().await?.as_ref(),
1824            b"fips event provider site"
1825        );
1826        assert_eq!(provider.queries.load(Ordering::Relaxed), 1);
1827        handle.abort();
1828        Ok(())
1829    }
1830
1831    #[tokio::test]
1832    async fn loopback_event_publication_uses_configured_provider() -> Result<()> {
1833        let temp_dir = TempDir::new()?;
1834        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1835        let keys = Keys::generate();
1836        let event = EventBuilder::new(Kind::TextNote, "provider test").sign_with_keys(&keys)?;
1837        let provider = Arc::new(StaticProvider {
1838            event: Some(VerifiedEvent::try_from(event.clone())?),
1839            queries: AtomicUsize::new(0),
1840            publishes: AtomicUsize::new(0),
1841            mode: PubsubProviderMode::LocalOnly,
1842        });
1843        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1844        let port = listener.local_addr()?.port();
1845        let server = HashtreeServer::new(store, "127.0.0.1:0".to_string())
1846            .with_nostr_provider(provider.clone());
1847        assert!(server.state.nostr_relay_urls.is_empty());
1848        let handle =
1849            tokio::spawn(async move { server.run_with_listener(listener).await.map(|_| ()) });
1850
1851        let response = reqwest::Client::new()
1852            .post(format!("http://127.0.0.1:{port}/api/nostr/events"))
1853            .json(&event)
1854            .send()
1855            .await?;
1856
1857        assert_eq!(response.status(), reqwest::StatusCode::ACCEPTED);
1858        assert_eq!(provider.publishes.load(Ordering::Relaxed), 1);
1859        handle.abort();
1860        Ok(())
1861    }
1862
1863    #[tokio::test]
1864    async fn loopback_root_publication_is_immediately_resolvable_without_provider_replay(
1865    ) -> Result<()> {
1866        let temp_dir = TempDir::new()?;
1867        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1868        let keys = Keys::generate();
1869        let tree_name = "webvm-e2e";
1870        let root_hash = "11".repeat(32);
1871        let encryption_key = "22".repeat(32);
1872        let event = EventBuilder::new(Kind::Custom(30064), &root_hash)
1873            .tags(vec![
1874                nostr::Tag::identifier(tree_name),
1875                nostr::Tag::custom(nostr::TagKind::custom("l"), vec!["hashtree".to_string()]),
1876                nostr::Tag::custom(nostr::TagKind::custom("l"), vec!["git".to_string()]),
1877                nostr::Tag::custom(nostr::TagKind::custom("hash"), vec![root_hash.clone()]),
1878                nostr::Tag::custom(nostr::TagKind::custom("key"), vec![encryption_key.clone()]),
1879            ])
1880            .sign_with_keys(&keys)?;
1881        let provider = Arc::new(StaticProvider {
1882            event: None,
1883            queries: AtomicUsize::new(0),
1884            publishes: AtomicUsize::new(0),
1885            mode: PubsubProviderMode::LocalOnly,
1886        });
1887        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1888        let port = listener.local_addr()?.port();
1889        let server = HashtreeServer::new(store, "127.0.0.1:0".to_string())
1890            .with_nostr_provider(provider.clone());
1891        assert!(server.state.nostr_relay_urls.is_empty());
1892        let handle =
1893            tokio::spawn(async move { server.run_with_listener(listener).await.map(|_| ()) });
1894        let client = reqwest::Client::new();
1895
1896        let publish = client
1897            .post(format!("http://127.0.0.1:{port}/api/nostr/events"))
1898            .json(&event)
1899            .send()
1900            .await?;
1901        assert_eq!(publish.status(), reqwest::StatusCode::ACCEPTED);
1902
1903        let npub = keys.public_key().to_bech32()?;
1904        let resolved = client
1905            .get(format!(
1906                "http://127.0.0.1:{port}/api/nostr/resolve/{npub}/{tree_name}?refresh=1"
1907            ))
1908            .send()
1909            .await?
1910            .json::<serde_json::Value>()
1911            .await?;
1912
1913        assert_eq!(resolved["hash"], root_hash);
1914        assert_eq!(resolved["key_tag"], encryption_key);
1915        assert_eq!(resolved["source"], "local-relay");
1916        assert_eq!(provider.publishes.load(Ordering::Relaxed), 1);
1917        assert_eq!(provider.queries.load(Ordering::Relaxed), 1);
1918        handle.abort();
1919        Ok(())
1920    }
1921
1922    #[tokio::test]
1923    async fn nostr_profile_route_returns_latest_metadata_event() -> Result<()> {
1924        let temp_dir = TempDir::new()?;
1925        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1926        let graph_store = {
1927            let _guard = crate::socialgraph::test_lock().await;
1928            crate::socialgraph::open_test_social_graph_store_with_mapsize(
1929                &temp_dir.path().join("relay-db"),
1930                Some(128 * 1024 * 1024),
1931            )?
1932        };
1933        let backend: Arc<dyn crate::socialgraph::SocialGraphBackend> = graph_store;
1934        let relay = Arc::new(NostrRelay::new(
1935            backend,
1936            temp_dir.path().to_path_buf(),
1937            HashSet::new(),
1938            None,
1939            NostrRelayConfig {
1940                spambox_db_max_bytes: 0,
1941                ..Default::default()
1942            },
1943        )?);
1944
1945        let author = Keys::generate();
1946        let older = EventBuilder::new(
1947            Kind::Metadata,
1948            json!({ "name": "older", "about": "before" }).to_string(),
1949        )
1950        .custom_created_at(Timestamp::from_secs(10))
1951        .sign_with_keys(&author)?;
1952        let newer = EventBuilder::new(
1953            Kind::Metadata,
1954            json!({ "name": "newer", "about": "after" }).to_string(),
1955        )
1956        .custom_created_at(Timestamp::from_secs(20))
1957        .sign_with_keys(&author)?;
1958
1959        relay.ingest_trusted_event(older).await?;
1960        relay.ingest_trusted_event(newer.clone()).await?;
1961
1962        let (port, handle) = spawn_test_server_with_nostr_relay(store, relay).await?;
1963        let response = reqwest::get(format!(
1964            "http://127.0.0.1:{port}/api/nostr/profile/{}",
1965            author.public_key().to_hex()
1966        ))
1967        .await?;
1968
1969        assert_eq!(response.status(), reqwest::StatusCode::OK);
1970        let payload: serde_json::Value = response.json().await?;
1971        assert_eq!(payload["profile"]["name"].as_str(), Some("newer"),);
1972        assert_eq!(payload["profile"]["about"].as_str(), Some("after"));
1973        assert_eq!(payload["created_at"].as_u64(), Some(20));
1974        let expected_event_id = newer.id.to_hex();
1975        assert_eq!(
1976            payload["event_id"].as_str(),
1977            Some(expected_event_id.as_str())
1978        );
1979
1980        handle.abort();
1981        Ok(())
1982    }
1983}