Skip to main content

did_hosting_server/
server.rs

1use std::sync::{Arc, OnceLock};
2use std::time::Duration;
3
4use affinidi_did_resolver_cache_sdk::DIDCacheClient;
5use affinidi_messaging_didcomm_service::{
6    DIDCommService, DIDCommServiceConfig, ListenerConfig, Protocols, RestartPolicy, RetryConfig,
7};
8use affinidi_tdk::secrets_resolver::ThreadedSecretsResolver;
9use axum::routing::get;
10use did_hosting_common::server::domain::parse_trusted_cidrs;
11use did_hosting_common::server::store::{KS_ACL, KS_DIDS, KS_SESSIONS};
12use ipnetwork::IpNetwork;
13
14use did_hosting_common::server::auth::extractor::AuthState;
15use did_hosting_common::server::didcomm_profile::{
16    advertised_protocols, build_tdk_profile_for_identity, reconcile_listener_protocols,
17    wait_for_did_resolution,
18};
19use did_hosting_common::server::identity::{self, ServiceIdentity};
20use did_hosting_common::server::init;
21use tokio_util::sync::CancellationToken;
22
23use crate::auth::jwt::JwtKeys;
24use crate::auth::session::cleanup_expired_sessions;
25use crate::config::{AppConfig, AuthConfig};
26use crate::control_register;
27use crate::did_ops::cleanup_empty_dids;
28use crate::error::AppError;
29use crate::messaging;
30use crate::routes;
31use crate::secret_store::ServerSecrets;
32use crate::stats;
33use crate::store::{KeyspaceHandle, Store};
34use tokio::sync::{oneshot, watch};
35use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer};
36use tracing::{Level, debug, error, info, warn};
37
38#[derive(Clone)]
39pub struct AppState {
40    pub store: Store,
41    pub sessions_ks: KeyspaceHandle,
42    pub acl_ks: KeyspaceHandle,
43    pub dids_ks: KeyspaceHandle,
44    pub config: Arc<AppConfig>,
45    pub did_resolver: Option<DIDCacheClient>,
46    pub secrets_resolver: Option<Arc<ThreadedSecretsResolver>>,
47    /// The service's own DID identity: every generation of key material still
48    /// honoured, and the kids each one answers to. `did_resolver` and
49    /// `secrets_resolver` above are cheap clones taken from this; it is the
50    /// source of truth for which kids they answer to, and the listener's
51    /// profile is built from it so the two cannot drift apart.
52    pub identity: Option<Arc<ServiceIdentity>>,
53    /// The running messaging service, once started.
54    ///
55    /// Lifted into `AppState` (it used to be a local in `run()`, unreachable at
56    /// runtime) so listeners can be hot-swapped without bouncing the process:
57    /// `remove_listener` / `add_listener` take `&self`, which is what lets a
58    /// rotation rebuild the profile in place. A `OnceLock` suffices precisely
59    /// because the *service* is never replaced — only its listeners are.
60    pub didcomm_service: Arc<OnceLock<DIDCommService>>,
61    pub jwt_keys: Option<Arc<JwtKeys>>,
62    pub signing_key_bytes: Option<[u8; 32]>,
63    pub http_client: reqwest::Client,
64    pub stats_collector: Option<Arc<stats::StatsCollector>>,
65    /// In-memory cache for DID content (did.jsonl). TTL-based eviction on read.
66    pub did_cache: Arc<crate::cache::ContentCache>,
67    /// Parsed `server.trusted_proxy_cidrs` — peers inside this set
68    /// have their `Forwarded` / `X-Forwarded-Host` headers honoured
69    /// for request-host detection (multi-domain, T19/T21).
70    pub trusted_proxy_cidrs: Arc<Vec<IpNetwork>>,
71}
72
73impl AppState {
74    /// Unwrap the DIDComm auth components, returning an error if any are not configured.
75    pub fn require_didcomm_auth(
76        &self,
77    ) -> Result<(&DIDCacheClient, &ThreadedSecretsResolver, &JwtKeys), AppError> {
78        let did_resolver = self
79            .did_resolver
80            .as_ref()
81            .ok_or_else(|| AppError::Authentication("DID resolver not configured".into()))?;
82        let secrets_resolver = self
83            .secrets_resolver
84            .as_ref()
85            .ok_or_else(|| AppError::Authentication("secrets resolver not configured".into()))?;
86        let jwt_keys = self
87            .jwt_keys
88            .as_ref()
89            .ok_or_else(|| AppError::Authentication("JWT keys not configured".into()))?;
90        Ok((did_resolver, secrets_resolver.as_ref(), jwt_keys.as_ref()))
91    }
92}
93
94impl AuthState for AppState {
95    fn jwt_keys(&self) -> Option<&Arc<JwtKeys>> {
96        self.jwt_keys.as_ref()
97    }
98
99    fn sessions_ks(&self) -> &KeyspaceHandle {
100        &self.sessions_ks
101    }
102}
103
104pub async fn run(config: AppConfig, store: Store, secrets: ServerSecrets) -> Result<(), AppError> {
105    // Open keyspace handles
106    let sessions_ks = store.keyspace(KS_SESSIONS)?;
107    let acl_ks = store.keyspace(KS_ACL)?;
108    let dids_ks = store.keyspace(KS_DIDS)?;
109
110    // Integrity check on DID keyspace
111    match dids_ks.verify_integrity().await {
112        Ok(0) => debug!("store integrity check passed"),
113        Ok(n) => warn!(
114            corrupted = n,
115            "store integrity check found corrupted entries"
116        ),
117        Err(e) => warn!(error = %e, "store integrity check failed"),
118    }
119
120    // First-boot multi-domain init (daemon parity with
121    // did-hosting-daemon/src/main.rs:544-622). Three idempotent steps:
122    //
123    //   1. seed_domains_first_boot — populates KS_DOMAINS from
124    //      `[hosting] bootstrap_domains`, falling back to legacy
125    //      `public_url` host on upgrade. Without this, the
126    //      resolve-side safety check (assert_resolution_allowed) takes
127    //      the "permissive skip" branch on every resolve and emits a
128    //      warn-log per request — annoying noise on standalone v0.6
129    //      → v0.7 upgrades.
130    //   2. seed_assignments_first_boot — same tier chain, populates
131    //      KS_ASSIGNMENTS so this server knows which domains it
132    //      serves (matters once a control plane starts driving
133    //      MSG_DOMAIN_ASSIGN / unassign).
134    //   3. MigrationRunner::run_pending — runs T13 M-01 which fills
135    //      DidRecord.domain for legacy records by parsing the
136    //      embedded did_id host. Required for write-side checks
137    //      that key on domain once domains are configured.
138    //
139    // All three are idempotent (markers in `meta` keyspace gate the
140    // migration; existing keyspace entries short-circuit the seeds),
141    // so re-running on every boot is cheap.
142    run_first_boot_init(&store, &config).await;
143    // Auto-bootstrap DIDs if public_url is set and they don't exist yet
144    let config = auto_bootstrap_dids(config, &store, &dids_ks, &secrets).await;
145
146    // Load the service's own identity (requires server_did). Resolves the DID
147    // document for the *real* verification-method key IDs and seeds the secrets
148    // resolver under them, rather than assuming `#key-0` / `#key-1`.
149    let identity = identity::load_identity(
150        config.server_did.as_deref(),
151        config.mediator_did.as_deref(),
152        identity::ProtocolSet {
153            didcomm: config.features.didcomm,
154            tsp: config.features.tsp,
155        },
156        &secrets,
157        &store,
158    )
159    .await;
160    let did_resolver = identity.as_ref().map(|i| i.did_resolver.clone());
161    let secrets_resolver = identity.as_ref().map(|i| i.secrets_resolver.clone());
162
163    // Initialize JWT keys independently — needed by both DIDComm and passkey auth
164    let jwt_keys = init::init_jwt_keys(&secrets);
165
166    // Extract raw signing key bytes for pack_signed operations
167    let signing_key_bytes = init::decode_multibase_ed25519_key(&secrets.signing_key).ok();
168
169    // Always bind TCP — the server must serve public DID documents even when
170    // the management REST API is disabled. The rest_api flag controls whether
171    // /api/* routes are included, not whether HTTP is served.
172    let addr = format!("{}:{}", config.server.host, config.server.port);
173    let std_listener = {
174        let listener = std::net::TcpListener::bind(&addr).map_err(AppError::Io)?;
175        listener.set_nonblocking(true).map_err(AppError::Io)?;
176        info!("server listening addr={addr}");
177        listener
178    };
179
180    // Gather storage thread inputs before moving config into Arc
181    let storage_sessions_ks = sessions_ks.clone();
182    let storage_dids_ks = dids_ks.clone();
183    let storage_auth_config = config.auth.clone();
184    let has_auth = jwt_keys.is_some();
185
186    let upload_body_limit = config.limits.upload_body_limit;
187    let stats_config = config.stats.clone();
188
189    // Initialize in-memory stats collector (starts at zero — no disk persistence)
190    let stats_collector = {
191        let collector = stats::StatsCollector::new();
192        let total_dids = storage_dids_ks
193            .prefix_iter_raw("did:")
194            .await
195            .map(|v| v.len())
196            .unwrap_or(0) as u64;
197        collector.set_total_dids(total_dids);
198        info!(total_dids, "stats collector initialized");
199        Arc::new(collector)
200    };
201
202    let (parsed_cidrs, bad_cidrs) = parse_trusted_cidrs(&config.server.trusted_proxy_cidrs);
203    if !bad_cidrs.is_empty() {
204        warn!(
205            bad_cidrs = ?bad_cidrs,
206            "server.trusted_proxy_cidrs contains unparseable entries; ignoring them"
207        );
208    }
209
210    let state = AppState {
211        store: store.clone(),
212        sessions_ks,
213        acl_ks,
214        dids_ks,
215        config: Arc::new(config),
216        did_resolver,
217        secrets_resolver,
218        identity,
219        didcomm_service: Arc::new(OnceLock::new()),
220        jwt_keys,
221        signing_key_bytes,
222        http_client: reqwest::Client::builder()
223            .timeout(Duration::from_secs(30))
224            .connect_timeout(Duration::from_secs(10))
225            .redirect(reqwest::redirect::Policy::none())
226            .build()
227            .expect("failed to build HTTP client"),
228        stats_collector: Some(stats_collector.clone()),
229        did_cache: Arc::new(crate::cache::ContentCache::new(Duration::from_secs(300))),
230        trusted_proxy_cidrs: Arc::new(parsed_cidrs),
231    };
232
233    // Log startup configuration
234    info!("--- enabled services ---");
235    info!(
236        "  REST API : {}",
237        if state.config.features.rest_api {
238            "enabled"
239        } else {
240            "disabled"
241        }
242    );
243    info!(
244        "  DIDComm  : {}",
245        if state.config.features.didcomm {
246            "enabled"
247        } else {
248            "disabled"
249        }
250    );
251    if let Some(ref did) = state.config.server_did {
252        info!("  server DID   : {did}");
253    }
254    if let Some(ref did) = state.config.mediator_did {
255        info!("  mediator DID : {did}");
256    }
257
258    // Separate shutdown channels for ordered shutdown (DIDComm → REST → Storage)
259    let (rest_shutdown_tx, rest_shutdown_rx) = watch::channel(false);
260    let (storage_shutdown_tx, storage_shutdown_rx) = watch::channel(false);
261
262    // REST ready signal — DIDComm waits for this before starting
263    let (rest_ready_tx, rest_ready_rx) = oneshot::channel::<()>();
264
265    // 1. Spawn HTTP thread first — must be serving before DIDComm starts
266    //    (the server's own DID needs to be resolvable for mediator auth)
267    let mut rest_shutdown = rest_shutdown_rx.clone();
268    let rest_state = state.clone();
269    let rest_handle = std::thread::Builder::new()
270        .name("webvh-rest".into())
271        .spawn(move || {
272            run_rest_thread(
273                std_listener,
274                rest_state,
275                upload_body_limit,
276                &mut rest_shutdown,
277                rest_ready_tx,
278            )
279        })
280        .map_err(|e| AppError::Internal(format!("failed to spawn HTTP thread: {e}")))?;
281
282    // 2. Spawn storage thread (independent cleanup, flush, sync)
283    let mut storage_shutdown = storage_shutdown_rx.clone();
284    let storage_collector = stats_collector.clone();
285    let storage_http = state.http_client.clone();
286    let storage_control_url = state.config.control_url.clone();
287    let storage_server_did = state.config.server_did.clone();
288    let storage_stats_config = stats_config;
289    let storage_handle = std::thread::Builder::new()
290        .name("webvh-storage".into())
291        .spawn(move || {
292            run_storage_thread(
293                StorageThreadParams {
294                    store,
295                    sessions_ks: storage_sessions_ks,
296                    dids_ks: storage_dids_ks,
297                    auth_config: storage_auth_config,
298                    has_auth,
299                    collector: storage_collector,
300                    stats_config: storage_stats_config,
301                    http: storage_http,
302                    control_url: storage_control_url,
303                    server_did: storage_server_did,
304                },
305                &mut storage_shutdown,
306            )
307        })
308        .map_err(|e| AppError::Internal(format!("failed to spawn storage thread: {e}")))?;
309
310    // 3. Wait for REST to be serving before starting DIDComm
311    //    (the server's own DID needs to be resolvable for mediator auth)
312    let _ = rest_ready_rx.await;
313
314    // Now that we are serving HTTP, resolve our *own* DID for the first time.
315    // A self-hosting service cannot resolve its own DID at boot — it is the thing
316    // that serves it — so `load_identity` came up on guessed `#key-0`/`#key-1`
317    // kids and persisted nothing. This is the first moment the document is
318    // fetchable, and it must run before the listener is built on the guess.
319    if let Err(e) = crate::identity_rotation::reload_now(&state).await {
320        warn!("failed to establish the service identity from its DID document: {e}");
321    }
322
323    // 4. Start the mediator messaging service (DIDComm and/or TSP) — one
324    //    connection for both receiving and sending. TSP-only deployments
325    //    (didcomm off, tsp on) must still start it.
326    let didcomm_shutdown = CancellationToken::new();
327    if state.config.features.didcomm || state.config.features.tsp {
328        match start_didcomm_service(&state, didcomm_shutdown.clone()).await {
329            Ok(Some(svc)) => {
330                let _ = state.didcomm_service.set(svc);
331            }
332            Ok(None) => {}
333            Err(e) => warn!("failed to start DIDComm service: {e}"),
334        }
335    }
336    let didcomm_service = state.didcomm_service.get();
337
338    // 5. Register with control plane via DIDComm (uses the shared connection)
339    if let Some(svc) = didcomm_service
340        && state.config.control_did.is_some()
341    {
342        let reg_state = state.clone();
343        let reg_svc = svc.clone();
344        tokio::spawn(async move {
345            control_register::register_via_didcomm(&reg_state, &reg_svc).await;
346        });
347    }
348
349    // 6. Spawn DIDComm stats sync task (runs on main tokio runtime)
350    let stats_sync_shutdown = CancellationToken::new();
351    let didcomm_sync_interval = state.config.stats.sync_interval_secs;
352    if let (Some(svc), Some(control_did), Some(server_did)) = (
353        didcomm_service,
354        state.config.control_did.as_ref(),
355        state.config.server_did.as_ref(),
356    ) && didcomm_sync_interval > 0
357    {
358        let token = stats_sync_shutdown.clone();
359        let svc = svc.clone();
360        let control_did = control_did.clone();
361        let server_did = server_did.clone();
362        let collector = stats_collector.clone();
363        tokio::spawn(async move {
364            let mut timer =
365                tokio::time::interval(Duration::from_secs(didcomm_sync_interval.max(1)));
366            timer.tick().await; // skip first tick
367            loop {
368                tokio::select! {
369                    _ = timer.tick() => {
370                        stats::sync_to_control_didcomm(
371                            &svc,
372                            &server_did,
373                            &control_did,
374                            &collector,
375                        ).await;
376                    }
377                    _ = token.cancelled() => break,
378                }
379            }
380        });
381    }
382
383    // 6b. Reconnect to any mediator we rotated *away* from but whose grace period
384    // has not elapsed. Peers holding a stale DID document are still delivering
385    // there, and a restart part-way through the window must not abandon that
386    // queue. No-op unless a rotation actually changed the mediator.
387    crate::identity_rotation::resume_mediator_drains(&state);
388
389    // 7. Spawn the identity sweep. Expires generations whose grace period has
390    // elapsed (so a superseded key stops decrypting) and backstops identity
391    // changes that never came through our own publish or sync path — one
392    // applied out-of-band, or while this process was down.
393    let (identity_shutdown_tx, identity_shutdown_rx) = watch::channel(false);
394    let identity_state = state.clone();
395    let identity_handle = tokio::spawn(async move {
396        crate::identity_rotation::run_identity_sweep_loop(identity_state, identity_shutdown_rx)
397            .await;
398    });
399
400    // Wait for shutdown signal
401    init::shutdown_signal().await;
402
403    // Ordered shutdown: identity → stats sync → DIDComm → REST → Storage
404    let mut any_panic = false;
405
406    let _ = identity_shutdown_tx.send(true);
407    if let Err(e) = identity_handle.await {
408        warn!("identity sweep task didn't shut down cleanly: {e}");
409    }
410
411    stats_sync_shutdown.cancel();
412    didcomm_shutdown.cancel();
413    if let Some(svc) = didcomm_service {
414        svc.shutdown().await;
415        info!("DIDComm service stopped");
416    }
417
418    let _ = rest_shutdown_tx.send(true);
419    {
420        let handle = rest_handle;
421        match tokio::time::timeout(
422            Duration::from_secs(30),
423            tokio::task::spawn_blocking(move || handle.join()),
424        )
425        .await
426        {
427            Ok(Ok(Ok(()))) => info!("REST thread stopped"),
428            Ok(Ok(Err(_panic))) => {
429                error!("REST thread panicked");
430                any_panic = true;
431            }
432            Ok(Err(e)) => {
433                error!("failed to join REST thread: {e}");
434                any_panic = true;
435            }
436            Err(_) => {
437                error!("REST thread shutdown timed out (30s)");
438                any_panic = true;
439            }
440        }
441    }
442
443    let _ = storage_shutdown_tx.send(true);
444    match tokio::time::timeout(
445        Duration::from_secs(30),
446        tokio::task::spawn_blocking(move || storage_handle.join()),
447    )
448    .await
449    {
450        Ok(Ok(Ok(()))) => info!("storage thread stopped"),
451        Ok(Ok(Err(_panic))) => {
452            error!("storage thread panicked");
453            any_panic = true;
454        }
455        Ok(Err(e)) => {
456            error!("failed to join storage thread: {e}");
457            any_panic = true;
458        }
459        Err(_) => {
460            error!("storage thread shutdown timed out (30s)");
461            any_panic = true;
462        }
463    }
464
465    if any_panic {
466        return Err(AppError::Internal("one or more threads panicked".into()));
467    }
468
469    info!("server shut down");
470    Ok(())
471}
472
473// ---------------------------------------------------------------------------
474// DIDComm service startup
475// ---------------------------------------------------------------------------
476
477pub async fn start_didcomm_service(
478    state: &AppState,
479    shutdown: CancellationToken,
480) -> Result<Option<DIDCommService>, AppError> {
481    let identity = match state.identity.as_ref() {
482        Some(identity) => identity,
483        None => {
484            info!("DIDComm not configured — server_did not set");
485            return Ok(None);
486        }
487    };
488
489    let mediator_did = match identity.mediator_did() {
490        Some(did) => did,
491        None => {
492            info!("mediator_did not configured — DIDComm messaging disabled");
493            return Ok(None);
494        }
495    };
496
497    // Block until the mediator DID document is resolvable. On a cold start
498    // the mediator DID may be hosted by a control plane / did-hosting-server that
499    // has not yet published its log, so we retry instead of starting the
500    // listener against an unreachable mediator (which surfaces as a cryptic
501    // "No Mediator is configured for this Profile" later).
502    wait_for_did_resolution(&mediator_did, "mediator", &identity.did_resolver, &shutdown).await?;
503
504    // Carries the key material of every live generation, keyed on the kids the
505    // DID document actually resolved to — the same kids the secrets resolver
506    // was seeded with.
507    let profile = build_tdk_profile_for_identity("server", identity, Some(&mediator_did)).await?;
508
509    // Transport selection — DIDComm and/or TSP ride the same mediator
510    // socket. Inbound TSP frames (sync/domain pushes from the control
511    // plane's outbox) are routed to the `ServerTspHandler` when TSP is on.
512    // "neither flag set but a mediator is configured" defaults to
513    // DIDComm-only for back-compat.
514    //
515    // The union across live generations, not just the current one's config
516    // flags — a generation retiring out of DIDComm still has peers delivering
517    // to it until it expires, and the single listener has to carry both.
518    //
519    // Then reconcile against what our own DID document actually advertises: the
520    // document is authoritative for how peers reach us, so the listener must
521    // carry every transport it advertises. Without this, a node whose config
522    // disables a transport its DID still advertises (e.g. `features.tsp = false`
523    // while the DID carries `TSPTransport`, since `did-host-didcomm` advertises
524    // both) silently drops — and acks — every inbound frame on that transport.
525    let advertised = advertised_protocols(&identity.did, Some(&identity.did_resolver)).await;
526    let transports = reconcile_listener_protocols(identity.protocols(), advertised, &identity.did);
527    let didcomm_enabled = transports.didcomm;
528    let tsp_enabled = transports.tsp;
529    let protocols = match (didcomm_enabled, tsp_enabled) {
530        (true, true) => Protocols::BOTH,
531        (false, true) => Protocols::TSP_ONLY,
532        _ => Protocols::DIDCOMM_ONLY,
533    };
534
535    let listener = ListenerConfig {
536        id: "server".into(),
537        profile,
538        restart_policy: RestartPolicy::Always {
539            backoff: RetryConfig::default(),
540        },
541        auto_delete: true,
542        protocols,
543        ..Default::default()
544    };
545
546    let router = messaging::build_server_router(state.clone())
547        .map_err(|e| AppError::Internal(format!("failed to build DIDComm router: {e}")))?;
548
549    let config = DIDCommServiceConfig {
550        listeners: vec![listener],
551    };
552
553    let svc = if tsp_enabled {
554        info!("starting messaging service with DIDComm + TSP on the mediator connection");
555        DIDCommService::start_with_tsp(
556            config,
557            router,
558            crate::tsp::ServerTspHandler::new(state.clone()),
559            shutdown,
560        )
561        .await
562    } else {
563        info!("starting DIDComm service with mediator connection");
564        DIDCommService::start(config, router, shutdown).await
565    }
566    .map_err(|e| AppError::Internal(format!("failed to start messaging service: {e}")))?;
567
568    info!(
569        tsp = tsp_enabled,
570        server_did = %identity.did,
571        "messaging service started"
572    );
573    Ok(Some(svc))
574}
575
576// ---------------------------------------------------------------------------
577// REST thread
578// ---------------------------------------------------------------------------
579
580fn run_rest_thread(
581    std_listener: std::net::TcpListener,
582    state: AppState,
583    upload_body_limit: usize,
584    shutdown_rx: &mut watch::Receiver<bool>,
585    ready_tx: oneshot::Sender<()>,
586) {
587    let rt = tokio::runtime::Builder::new_multi_thread()
588        .worker_threads(4)
589        .enable_all()
590        .build()
591        .expect("failed to build REST runtime");
592
593    rt.block_on(async {
594        let listener = tokio::net::TcpListener::from_std(std_listener)
595            .expect("failed to convert std TcpListener to tokio TcpListener");
596
597        // When rest_api is disabled, serve only public DID routes + health.
598        // When enabled, serve full management API + public DID routes.
599        let base_router = if state.config.features.rest_api {
600            info!("HTTP thread started (REST API + public DID serving)");
601            routes::router(upload_body_limit)
602        } else {
603            info!("HTTP thread started (public DID serving only, REST API disabled)");
604            routes::router_public_only().fallback(routes::did_public::serve_public)
605        };
606
607        let app = base_router
608            .with_state(state)
609            .layer(
610                TraceLayer::new_for_http()
611                    .make_span_with(DefaultMakeSpan::new().level(Level::DEBUG))
612                    .on_response(
613                        DefaultOnResponse::new()
614                            .level(Level::DEBUG)
615                            .latency_unit(tower_http::LatencyUnit::Millis),
616                    ),
617            )
618            .layer(axum::middleware::from_fn(
619                did_hosting_common::server::security_headers,
620            ))
621            // Allow browser-based resolvers to fetch public DID documents
622            // cross-origin. Read-only, unauthenticated, wildcard origin.
623            .layer(did_hosting_common::server::public_resolution_cors())
624            .route("/api/health", get(routes::health::health));
625
626        // Signal that REST is ready to serve
627        let _ = ready_tx.send(());
628
629        let shutdown_rx = shutdown_rx.clone();
630        axum::serve(listener, app)
631            .with_graceful_shutdown(async move {
632                let mut rx = shutdown_rx;
633                let _ = rx.changed().await;
634            })
635            .await
636            .expect("axum serve failed");
637
638        info!("REST thread shutting down");
639    });
640}
641
642// ---------------------------------------------------------------------------
643// Storage thread
644// ---------------------------------------------------------------------------
645
646/// Parameters for the background storage thread.
647struct StorageThreadParams {
648    store: Store,
649    sessions_ks: KeyspaceHandle,
650    dids_ks: KeyspaceHandle,
651    auth_config: AuthConfig,
652    has_auth: bool,
653    collector: Arc<stats::StatsCollector>,
654    stats_config: crate::config::StatsConfig,
655    http: reqwest::Client,
656    control_url: Option<String>,
657    server_did: Option<String>,
658}
659
660fn run_storage_thread(params: StorageThreadParams, shutdown_rx: &mut watch::Receiver<bool>) {
661    let StorageThreadParams {
662        store,
663        sessions_ks,
664        dids_ks,
665        auth_config,
666        has_auth,
667        collector,
668        stats_config,
669        http,
670        control_url,
671        server_did,
672    } = params;
673    let rt = tokio::runtime::Builder::new_current_thread()
674        .enable_all()
675        .build()
676        .expect("failed to build storage runtime");
677
678    rt.block_on(async {
679        info!(
680            sync_interval = stats_config.sync_interval_secs,
681            "storage thread started"
682        );
683
684        let session_interval = Duration::from_secs(auth_config.session_cleanup_interval);
685        let did_ttl_seconds = auth_config.cleanup_ttl_minutes * 60;
686        let did_interval = Duration::from_secs(did_ttl_seconds.max(60));
687        let sync_enabled = stats_config.sync_interval_secs > 0 && control_url.is_some();
688        let sync_interval = Duration::from_secs(stats_config.sync_interval_secs.max(1));
689
690        let mut session_timer = tokio::time::interval(session_interval);
691        let mut did_timer = tokio::time::interval(did_interval);
692        let mut sync_timer = tokio::time::interval(sync_interval);
693
694        // First tick completes immediately; skip so cleanup doesn't run at startup
695        session_timer.tick().await;
696        did_timer.tick().await;
697        sync_timer.tick().await;
698
699        loop {
700            tokio::select! {
701                _ = session_timer.tick(), if has_auth => {
702                    if let Err(e) = cleanup_expired_sessions(&sessions_ks, auth_config.challenge_ttl).await {
703                        warn!("session cleanup error: {e}");
704                    }
705                }
706                _ = did_timer.tick() => {
707                    match cleanup_empty_dids(&dids_ks, did_ttl_seconds).await {
708                        Ok(0) => {}
709                        Ok(n) => {
710                            info!(count = n, "cleaned up empty DID records");
711                            // Adjust count for cleaned up records
712                            for _ in 0..n {
713                                collector.decrement_total_dids();
714                            }
715                        }
716                        Err(e) => warn!("DID cleanup error: {e}"),
717                    }
718                }
719                _ = sync_timer.tick(), if sync_enabled => {
720                    if let (Some(url), Some(did)) = (&control_url, &server_did) {
721                        stats::sync_to_control(&http, url, did, &collector).await;
722                    }
723                }
724                _ = shutdown_rx.changed() => {
725                    info!("storage thread shutting down");
726                    break;
727                }
728            }
729        }
730
731        // Persist store before closing
732        if let Err(e) = store.persist().await {
733            error!("failed to persist store on shutdown: {e}");
734        } else {
735            info!("store persisted");
736        }
737    });
738}
739
740// ---------------------------------------------------------------------------
741// Auto-bootstrap
742// ---------------------------------------------------------------------------
743
744/// Extract the mnemonic (path) from a `did:webvh` DID string.
745///
746/// Re-exported from `did_hosting_common::server::identity`, which needs the same
747/// parse to answer "was the DID just published our own?" — the question that
748/// gates the rotation trigger. One copy, so the two cannot drift.
749use did_hosting_common::server::identity::mnemonic_from_did;
750
751/// First-boot multi-domain init for the standalone server.
752///
753/// Mirrors the daemon's `did-hosting-daemon/src/main.rs:544-622` block
754/// so a standalone `did-hosting-server` deployment gets the same
755/// effective state on first boot. Three steps, in order:
756///
757/// 1. **Domain catalog seed** (`seed_domains_first_boot`) — populates
758///    `KS_DOMAINS` from `[hosting] bootstrap_domains` or the legacy
759///    `public_url` host. Without it, the resolve-side safety check
760///    (`assert_resolution_allowed`) takes its permissive skip branch
761///    on every resolve and emits a per-request warn-log.
762/// 2. **Assignment seed** (`seed_assignments_first_boot`) — same tier
763///    chain, populates `KS_ASSIGNMENTS`. Matters once a control plane
764///    starts driving `MSG_DOMAIN_ASSIGN` / unassign messages.
765/// 3. **Migration runner** (`MigrationRunner::run_pending`) — runs
766///    `m01_tag_did_records_with_domain` which fills
767///    `DidRecord.domain` for legacy records by parsing the embedded
768///    `did_id` host. Required for any write path that keys on
769///    `domain` once domains are configured.
770///
771/// All three are idempotent — subsequent boots short-circuit
772/// (existing keyspace entries / migration markers in `meta`) so this
773/// is cheap to call on every startup.
774///
775/// Failures log loudly but do **not** abort startup. The standalone
776/// server's design treats domain seeding as best-effort metadata: a
777/// legacy single-domain deployment without `bootstrap_domains`
778/// configured boots into the permissive-skip state (with warnings)
779/// rather than refusing to serve. The daemon's stricter
780/// `std::process::exit(1)` is appropriate there because the daemon
781/// owns the control plane and *must* have a domain catalog to make
782/// admin write paths work; the standalone server has no such
783/// guarantee.
784pub async fn run_first_boot_init(store: &Store, config: &AppConfig) {
785    // 1. Domain catalog
786    match did_hosting_common::server::domain::seed_domains_first_boot(
787        store,
788        &config.hosting.bootstrap_domains,
789        config.public_url.as_deref(),
790    )
791    .await
792    {
793        Ok(outcome) => info!(
794            tier = ?outcome.tier,
795            count = outcome.final_count,
796            default = ?outcome.default,
797            "first-boot domain seed"
798        ),
799        Err(e) => warn!(error = %e, "first-boot domain seed failed"),
800    }
801
802    // 2. Per-server assignments
803    let now = did_hosting_common::server::auth::session::now_epoch();
804    match did_hosting_common::server::assignment_seed::seed_assignments_first_boot(
805        store,
806        &config.hosting.bootstrap_domains,
807        config.public_url.as_deref(),
808        now,
809    )
810    .await
811    {
812        Ok(outcome) => info!(
813            tier = ?outcome.tier,
814            count = outcome.final_count,
815            "first-boot assignment seed"
816        ),
817        Err(e) => warn!(error = %e, "first-boot assignment seed failed"),
818    }
819
820    // 3. Storage migrations (T2 runner + T13 M-01)
821    let runner = did_hosting_common::server::migrations::MigrationRunner::new(
822        did_hosting_common::server::migrations::registry(),
823    );
824    match runner.run_pending(store).await {
825        Ok(summary) => info!(
826            applied = ?summary.applied,
827            skipped = ?summary.skipped,
828            "migration runner complete"
829        ),
830        Err(e) => warn!(error = %e, "migration runner failed"),
831    }
832}
833
834/// Verify DIDs in the store and log their status.
835///
836/// The server is a read-only edge node — it does not auto-create DIDs.
837/// The setup wizard or `bootstrap-did` CLI command creates the server's
838/// identity DID. This function only verifies and logs what's present.
839pub async fn auto_bootstrap_dids(
840    config: AppConfig,
841    _store: &Store,
842    dids_ks: &KeyspaceHandle,
843    _secrets: &ServerSecrets,
844) -> AppConfig {
845    // Verify server_did exists in store (if configured)
846    if let Some(ref server_did) = config.server_did
847        && let Some(mnemonic) = mnemonic_from_did(server_did)
848    {
849        let exists = dids_ks
850            .contains_key(format!("did:{mnemonic}"))
851            .await
852            .unwrap_or(false);
853        if exists {
854            info!(path = %mnemonic, "server DID loaded from store");
855        } else {
856            error!(
857                did = %server_did,
858                path = %mnemonic,
859                "server DID not found in store — DID resolution will fail"
860            );
861            error!("  To create it, run:  did-hosting-server bootstrap-did --path {mnemonic}");
862            error!("  Then update server_did in config.toml with the new DID");
863        }
864    }
865
866    // Log all DIDs present in the store
867    match dids_ks.prefix_iter_raw("did:").await {
868        Ok(entries) => {
869            let count = entries.len();
870            if count == 0 {
871                warn!("no DIDs in store — run `did-hosting-server bootstrap-did` to create one");
872            } else {
873                info!(count, "DIDs in store:");
874                for (key, _) in &entries {
875                    let mnemonic = String::from_utf8_lossy(key)
876                        .strip_prefix("did:")
877                        .unwrap_or("?")
878                        .to_string();
879                    info!(path = %mnemonic, "  DID loaded");
880                }
881            }
882        }
883        Err(e) => warn!("failed to list DIDs: {e}"),
884    }
885
886    config
887}
888
889#[cfg(test)]
890mod tests {
891    use super::*;
892
893    #[test]
894    fn mnemonic_from_did_simple() {
895        let did =
896            "did:webvh:QmaErmPvnHUDaaiM4phkDgrK58T49cxgmCUtKon9gwyWtJ:webvh.storm.ws:webvh:server1";
897        assert_eq!(mnemonic_from_did(did).unwrap(), "webvh/server1");
898    }
899
900    #[test]
901    fn mnemonic_from_did_single_path() {
902        let did = "did:webvh:QmABC:example.com:my-did";
903        assert_eq!(mnemonic_from_did(did).unwrap(), "my-did");
904    }
905
906    #[test]
907    fn mnemonic_from_did_deep_path() {
908        let did = "did:webvh:QmABC:example.com:people:staff:glenn";
909        assert_eq!(mnemonic_from_did(did).unwrap(), "people/staff/glenn");
910    }
911
912    #[test]
913    fn mnemonic_from_did_invalid() {
914        assert!(mnemonic_from_did("did:web:example.com").is_none());
915        assert!(mnemonic_from_did("not-a-did").is_none());
916    }
917}