1use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use did_hosting_common::server::operator_messages::WebvhServerMessages;
11use did_hosting_common::server::setup_recipe::{
12 EXIT_RECIPE_INVALID, ServiceKind, SetupRecipe, VtaMode, VtaSetupOutcome, active_backend,
13 apply_env_overrides, back_up_config, derive_did_path, inspect_existing, load_recipe,
14 print_recipe_banner, refuse_overwrite, require_service, resolve_admin_did,
15 resolve_secrets_config, run_uninstall_unchecked, run_vta_for_recipe, split_origin_and_did_path,
16 to_log_format,
17};
18use did_hosting_common::server::store::{KS_ACL, KS_DIDS};
19use did_hosting_common::server::vta_setup;
20use vta_sdk::provision_client::{EphemeralSetupKey, OperatorMessages};
21
22use crate::acl::{AclEntry, Role, store_acl_entry};
23use crate::auth::session::now_epoch;
24use crate::config::{
25 AppConfig, AuthConfig, FeaturesConfig, LimitsConfig, LogConfig, LogFormat, ServerConfig,
26 StatsConfig, StoreConfig, VtaConfig,
27};
28use crate::error::AppError;
29use crate::secret_store::{ServerSecrets, create_secret_store};
30use crate::setup::update_server_did_in_config;
31use crate::store::Store;
32
33pub async fn run_from_recipe(
35 recipe_path: &Path,
36 setup_key_file: Option<PathBuf>,
37 force_reprovision: bool,
38) -> Result<(), AppError> {
39 let recipe = load_recipe(recipe_path).map_err(|e| {
40 eprintln!(" [setup-recipe] {e}");
41 AppError::Config(format!("recipe load failed: {e}"))
42 })?;
43 require_service(&recipe, ServiceKind::Server).map_err(|e| AppError::Config(format!("{e}")))?;
44 apply_recipe(recipe, setup_key_file, force_reprovision).await
45}
46
47pub async fn apply_recipe(
51 mut recipe: SetupRecipe,
52 setup_key_file: Option<PathBuf>,
53 force_reprovision: bool,
54) -> Result<(), AppError> {
55 apply_env_overrides(&mut recipe);
56 print_recipe_banner("did-hosting-server", &recipe);
57
58 let secrets_config = resolve_secrets_config(&recipe, "did-hosting-server-secrets", "webvh");
61 let scan = inspect_existing(&secrets_config, &recipe.output.config_path).await;
62 if scan.is_provisioned() && !force_reprovision && !recipe.reprovision.force {
63 let err = refuse_overwrite(&recipe.output.config_path, &scan);
64 return Err(err);
65 }
66
67 if recipe.output.config_path.exists() {
71 back_up_config(&recipe.output.config_path)
72 .map_err(|e| AppError::Config(format!("config backup failed: {e}")))?;
73 }
74
75 let setup_key = match (recipe.deployment.vta_mode, setup_key_file.as_deref()) {
77 (VtaMode::Online, Some(path)) => Some(
78 EphemeralSetupKey::load_from(path)
79 .map_err(|e| AppError::Config(format!("load setup key: {e}")))?,
80 ),
81 (VtaMode::Online, None) => {
82 return Err(AppError::Config(
83 "online vta_mode requires --setup-key-file <path>. Run \
84 `did-hosting-server setup --setup-key-out <path>` first to mint and \
85 enrol an ephemeral did:key."
86 .into(),
87 ));
88 }
89 _ => None,
90 };
91
92 let offline_complete_seed = if recipe.deployment.vta_mode == VtaMode::OfflineComplete {
95 let store = did_hosting_common::server::secret_store::create_secret_store(
96 &secrets_config,
97 &recipe.output.config_path,
98 )?;
99 store.get_bootstrap_seed().await?
100 } else {
101 None
102 };
103
104 let messages: Arc<dyn OperatorMessages> = Arc::new(WebvhServerMessages);
105
106 let public_url_owned = recipe
113 .identity
114 .public_url
115 .clone()
116 .unwrap_or_default()
117 .trim_end_matches('/')
118 .to_string();
119 let context_id = recipe
120 .vta
121 .context_id
122 .clone()
123 .unwrap_or_else(|| "webvh".to_string());
124 let (origin, did_path) = split_origin_and_did_path(&public_url_owned);
125 let transport = recipe.identity.transport_selection()?;
129 let ask = matches!(
130 recipe.deployment.vta_mode,
131 VtaMode::Online | VtaMode::OfflinePrepare
132 )
133 .then(|| {
134 let shape = vta_setup::WebvhDidShape::Hosted {
135 origin: &origin,
136 did_path: &did_path,
137 mediator_did: recipe.identity.mediator_did.as_deref(),
138 transport,
139 remote: None,
140 };
141 vta_setup::build_webvh_provision_ask(
142 &context_id,
143 &shape,
144 Some(&format!("did-hosting-server setup — {context_id}")),
145 )
146 });
147
148 let outcome =
149 run_vta_for_recipe(&recipe, ask, messages, setup_key, offline_complete_seed).await?;
150
151 let (
154 server_did,
155 signing_priv,
156 ka_priv,
157 vta_did_persisted,
158 vta_url,
159 vta_credential_b64,
160 log_entry,
161 ) = match outcome {
162 VtaSetupOutcome::Online(o) => (
163 o.integration_did,
164 o.integration_signing_key_mb,
165 o.integration_ka_key_mb,
166 Some(o.vta_did),
167 o.vta_url,
168 Some(o.vta_credential_b64),
169 o.did_log_entry,
170 ),
171 VtaSetupOutcome::Offline(o) => (
172 o.did,
173 o.signing_key_multibase,
174 o.key_agreement_multibase,
175 Some(o.vta_did),
176 o.vta_url,
177 None,
178 o.log_entry,
179 ),
180 VtaSetupOutcome::OfflinePreparedOnly(info) => {
181 persist_offline_prepare(&recipe, &info, &secrets_config).await?;
182 return Ok(());
183 }
184 VtaSetupOutcome::SelfManaged(_) => {
185 return Err(AppError::Config(
188 "self-managed mode is not supported for did-hosting-server".into(),
189 ));
190 }
191 };
192
193 let jwt_signing_key = did_hosting_common::server::vta_setup::generate_ed25519_multibase();
194
195 let host = recipe
196 .server
197 .host
198 .clone()
199 .unwrap_or_else(|| "0.0.0.0".to_string());
200 let port = recipe
201 .server
202 .port
203 .unwrap_or_else(|| SetupRecipe::default_port(ServiceKind::Server));
204 let log_level = recipe
205 .server
206 .log_level
207 .clone()
208 .unwrap_or_else(|| "info".to_string());
209 let log_format = recipe
210 .server
211 .log_format
212 .map(to_log_format)
213 .map(|f| match f {
214 did_hosting_common::server::config::LogFormat::Text => LogFormat::Text,
215 did_hosting_common::server::config::LogFormat::Json => LogFormat::Json,
216 })
217 .unwrap_or(LogFormat::Text);
218 let data_dir = recipe
219 .server
220 .data_dir
221 .clone()
222 .unwrap_or_else(|| SetupRecipe::default_data_dir(ServiceKind::Server));
223
224 let (didcomm, tsp) = recipe.identity.transport_flags()?;
225 let config = AppConfig {
226 features: FeaturesConfig {
227 didcomm,
228 tsp,
229 rest_api: false,
230 ..Default::default()
231 },
232 server_did: Some(server_did.clone()),
233 mediator_did: recipe.identity.mediator_did.clone(),
234 public_url: Some(public_url_owned.clone()),
235 server: ServerConfig {
236 host,
237 port,
238 trusted_proxies: Vec::new(),
239 trusted_proxy_cidrs: Vec::new(),
240 },
241 log: LogConfig {
242 level: log_level,
243 format: log_format,
244 },
245 store: StoreConfig {
246 data_dir,
247 ..StoreConfig::default()
248 },
249 auth: AuthConfig::default(),
250 hosting: crate::config::HostingConfig::default(),
251 secrets: secrets_config.clone(),
252 limits: LimitsConfig::default(),
253 watchers: Vec::new(),
254 control_url: recipe.identity.control_url.clone(),
255 control_did: recipe.identity.control_did.clone(),
256 vta: VtaConfig {
257 url: vta_url,
258 did: vta_did_persisted,
259 context_id: None,
260 },
261 stats: StatsConfig::default(),
262 identity: Default::default(),
263 config_path: recipe.output.config_path.clone(),
264 };
265
266 if let Some(parent) = recipe.output.config_path.parent()
267 && !parent.as_os_str().is_empty()
268 {
269 std::fs::create_dir_all(parent)?;
270 }
271 std::fs::write(
272 &recipe.output.config_path,
273 toml::to_string_pretty(&config)
274 .map_err(|e| AppError::Config(format!("toml encode: {e}")))?,
275 )?;
276 eprintln!(
277 " [setup-recipe] config written to {}",
278 recipe.output.config_path.display()
279 );
280
281 let server_secrets = ServerSecrets {
282 signing_key: signing_priv,
283 key_agreement_key: ka_priv,
284 jwt_signing_key,
285 vta_credential: vta_credential_b64,
286 retired: Vec::new(),
287 };
288 let secret_store = create_secret_store(&config)?;
289 secret_store.set(&server_secrets).await?;
290 eprintln!(
291 " [setup-recipe] secrets stored in {:?} backend",
292 active_backend(&recipe)
293 );
294
295 if recipe.deployment.vta_mode == VtaMode::OfflineComplete {
297 let post = did_hosting_common::server::secret_store::create_secret_store(
298 &secrets_config,
299 &recipe.output.config_path,
300 )?;
301 if let Err(e) = post.clear_bootstrap_seed().await {
302 eprintln!(" Warning: failed to clear bootstrap seed: {e}");
303 }
304 }
305
306 if let Some(log_entry) = log_entry.as_deref() {
308 let did_path = derive_did_path(&public_url_owned);
309 let store = Store::open(&config.store).await?;
310 let dids_ks = store.keyspace(KS_DIDS)?;
311 match crate::bootstrap::import_did_at_path(&store, &dids_ks, &did_path, log_entry, None)
312 .await
313 {
314 Ok(result) => {
315 eprintln!(
316 " [setup-recipe] server DID imported at '{did_path}' (scid={})",
317 result.scid
318 );
319 update_server_did_in_config(&recipe.output.config_path, &result.did_id)
320 .map_err(|e| AppError::Config(format!("update server_did: {e}")))?;
321 }
322 Err(e) => {
323 eprintln!(
324 " [setup-recipe] WARNING failed to import server DID: {e}\n retry: did-hosting-server bootstrap-did --path {did_path}"
325 );
326 }
327 }
328 }
329
330 if let Some(admin_did) = resolve_admin_did(&recipe) {
332 let store = Store::open(&config.store).await?;
333 let acl_ks = store.keyspace(KS_ACL)?;
334 let entry = AclEntry {
335 did: admin_did.clone(),
336 role: Role::Admin,
337 label: Some("Setup recipe admin".into()),
338 created_at: now_epoch(),
339 max_total_size: None,
340 max_did_count: None,
341
342 domains: did_hosting_common::server::domain::DomainScope::All,
343 };
344 store_acl_entry(&acl_ks, &entry).await?;
345 store.persist().await?;
346 eprintln!(" [setup-recipe] admin ACL entry added for {admin_did}");
347 }
348
349 eprintln!();
350 eprintln!(" [setup-recipe] setup complete");
351 eprintln!();
352 eprintln!(" Server DID: {server_did}");
353 eprintln!(
354 " Next: did-hosting-server --config {}",
355 recipe.output.config_path.display()
356 );
357 eprintln!();
358
359 Ok(())
360}
361
362async fn persist_offline_prepare(
372 recipe: &SetupRecipe,
373 info: &did_hosting_common::server::setup_recipe::OfflinePreparedInfo,
374 secrets_config: &did_hosting_common::server::config::SecretsConfig,
375) -> Result<(), AppError> {
376 let store = did_hosting_common::server::secret_store::create_secret_store(
377 secrets_config,
378 &recipe.output.config_path,
379 )?;
380 store.set_bootstrap_seed(&info.seed).await?;
381 print_offline_prepare_recap("did-hosting-server", recipe, info);
382 Ok(())
383}
384
385fn print_offline_prepare_recap(
389 binary: &str,
390 recipe: &SetupRecipe,
391 info: &did_hosting_common::server::setup_recipe::OfflinePreparedInfo,
392) {
393 eprintln!();
394 eprintln!(" [setup-recipe:offline-prepare] phase 1 complete");
395 eprintln!(
396 " [setup-recipe:offline-prepare] request_path = {}",
397 info.request_path.display()
398 );
399 eprintln!(
400 " [setup-recipe:offline-prepare] client_did = {}",
401 info.client_did
402 );
403 eprintln!(
404 " [setup-recipe:offline-prepare] nonce = {}",
405 info.nonce
406 );
407 eprintln!(" [setup-recipe:offline-prepare] seed stored in configured secret backend");
408 eprintln!();
409 eprintln!(" Next steps:");
410 eprintln!(
411 " 1. Ferry {} to your VTA admin.",
412 info.request_path.display()
413 );
414 eprintln!(" 2. Ask them to seal the response:");
415 eprintln!(
416 " vta bootstrap provision-integration --request <request-file> \\\n --out <bundle-file>"
417 );
418 eprintln!(" and to communicate the SHA-256 digest out-of-band.");
419 eprintln!(
420 " 3. Edit your recipe ({}):",
421 recipe.output.config_path.display()
422 );
423 eprintln!(" - set [deployment].vta_mode = \"offline-complete\"");
424 eprintln!(" - set [vta].bundle_path = \"<bundle-path>\"");
425 eprintln!(" - set [vta].expect_digest = \"<hex-digest>\"");
426 eprintln!(" 4. Re-run phase 2 (no TTY required):");
427 eprintln!(" {binary} setup --from <recipe>");
428 eprintln!();
429}
430
431pub async fn run_uninstall(config_path: &Path, yes: bool) -> Result<(), AppError> {
434 let config = AppConfig::load(Some(config_path.to_path_buf())).map_err(|e| {
436 AppError::Config(format!(
437 "failed to load {} for uninstall: {e}",
438 config_path.display()
439 ))
440 })?;
441
442 if !yes {
443 let confirmed =
444 did_hosting_common::server::setup_recipe::prompt_uninstall_confirmation(config_path)?;
445 if !confirmed {
446 eprintln!(" Aborted (DELETE not entered).");
447 return Ok(());
448 }
449 }
450
451 let report = run_uninstall_unchecked(&config.secrets, config_path, &[]).await?;
452
453 eprintln!();
454 eprintln!(" Uninstall complete:");
455 eprintln!(" secrets cleared: {}", report.secrets_cleared);
456 eprintln!(" bootstrap seed cleared: {}", report.seed_cleared);
457 for f in &report.files_removed {
458 eprintln!(" removed file: {}", f.display());
459 }
460 eprintln!();
461 Ok(())
462}
463
464pub fn map_exit_code(err: &AppError) -> i32 {
467 let msg = err.to_string();
468 if msg.contains("refusing to overwrite") {
469 did_hosting_common::server::setup_recipe::EXIT_REPROVISION_REFUSED
470 } else if msg.contains("VTA provision") || msg.contains("post-auth") {
471 did_hosting_common::server::setup_recipe::EXIT_VTA_POST_AUTH
472 } else if msg.contains("recipe") && (msg.contains("invalid") || msg.contains("missing")) {
473 EXIT_RECIPE_INVALID
474 } else {
475 1
476 }
477}