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 pub identity: Option<Arc<ServiceIdentity>>,
53 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 pub did_cache: Arc<crate::cache::ContentCache>,
67 pub trusted_proxy_cidrs: Arc<Vec<IpNetwork>>,
71}
72
73impl AppState {
74 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 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 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 run_first_boot_init(&store, &config).await;
143 let config = auto_bootstrap_dids(config, &store, &dids_ks, &secrets).await;
145
146 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 let jwt_keys = init::init_jwt_keys(&secrets);
165
166 let signing_key_bytes = init::decode_multibase_ed25519_key(&secrets.signing_key).ok();
168
169 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 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 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 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 let (rest_shutdown_tx, rest_shutdown_rx) = watch::channel(false);
260 let (storage_shutdown_tx, storage_shutdown_rx) = watch::channel(false);
261
262 let (rest_ready_tx, rest_ready_rx) = oneshot::channel::<()>();
264
265 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 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 let _ = rest_ready_rx.await;
313
314 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 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 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(®_state, ®_svc).await;
346 });
347 }
348
349 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; 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 crate::identity_rotation::resume_mediator_drains(&state);
388
389 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 init::shutdown_signal().await;
402
403 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
473pub 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 wait_for_did_resolution(&mediator_did, "mediator", &identity.did_resolver, &shutdown).await?;
503
504 let profile = build_tdk_profile_for_identity("server", identity, Some(&mediator_did)).await?;
508
509 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
576fn 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 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 .layer(did_hosting_common::server::public_resolution_cors())
624 .route("/api/health", get(routes::health::health));
625
626 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
642struct 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 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 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 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
740use did_hosting_common::server::identity::mnemonic_from_did;
750
751pub async fn run_first_boot_init(store: &Store, config: &AppConfig) {
785 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 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 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
834pub async fn auto_bootstrap_dids(
840 config: AppConfig,
841 _store: &Store,
842 dids_ks: &KeyspaceHandle,
843 _secrets: &ServerSecrets,
844) -> AppConfig {
845 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 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}