farm.rs (67001B)
1 use std::sync::atomic::{AtomicU64, Ordering}; 2 use std::time::{SystemTime, UNIX_EPOCH}; 3 4 use radroots_authority::RadrootsActorContext; 5 use radroots_events::contract::RadrootsActorRole; 6 use radroots_events::farm::{RadrootsFarm, RadrootsFarmLocation}; 7 use radroots_events::kinds::{KIND_FARM, KIND_PROFILE}; 8 use radroots_events::listing::RadrootsListingLocation; 9 use radroots_events::profile::{RadrootsProfile, RadrootsProfileType}; 10 use radroots_events_codec::d_tag::is_d_tag_base64url; 11 use radroots_events_codec::profile::encode::to_wire_parts_with_profile_type; 12 use radroots_sdk::{ 13 FarmEnqueuePublishRequest, FarmEnqueueReceipt, FarmPreparePublishRequest, FarmPublishPlan, 14 PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, PushOutboxRelayOutcomeKind, 15 PushOutboxRequest, SdkMutationState, 16 }; 17 use serde_json::json; 18 19 use crate::cli::global::{ 20 FarmCreateArgs, FarmFieldArg, FarmPublishArgs, FarmRebindArgs, FarmScopeArg, FarmScopedArgs, 21 FarmUpdateArgs, 22 }; 23 use crate::runtime::RuntimeError; 24 use crate::runtime::account::{self, AccountRecordView}; 25 use crate::runtime::config::{PublishTransport, RuntimeConfig, SignerBackend}; 26 use crate::runtime::farm_config::{ 27 self, FarmConfigDocument, FarmConfigScope, FarmConfigSelection, FarmListingDefaults, 28 FarmMissingField, FarmPublicationStatus, ResolvedFarmConfig, SUPPORTED_FARM_CONFIG_VERSION, 29 }; 30 use crate::runtime::local_events::append_local_work; 31 use crate::runtime::sdk::{ 32 CliSdkAdapterError, CliSdkSession, sdk_relay_target_policy, sdk_relay_url_policy, 33 validate_configured_signer_for_actor, 34 }; 35 use crate::runtime::signer::ActorWriteBindingError; 36 use crate::view::runtime::{ 37 FarmConfigDocumentView, FarmConfigSummaryView, FarmGetView, FarmListingDefaultsView, 38 FarmPublicationView, FarmPublishComponentView, FarmPublishEventView, FarmPublishView, 39 FarmRebindView, FarmSelectionView, FarmSetView, FarmSetupView, FarmStatusView, 40 RelayFailureView, 41 }; 42 43 const FARM_CONFIG_SOURCE: &str = "farm config · local first"; 44 const FARM_SELLER_ACTOR_SOURCE: &str = "farm_config"; 45 const SDK_FARM_WRITE_SOURCE: &str = "SDK farm publish · configured signer"; 46 const SDK_PROFILE_NOT_SUBMITTED_METHOD: &str = "sdk.farm.profile.not_submitted"; 47 const SDK_FARM_PUBLISH_METHOD: &str = "sdk.farm.publish.v1"; 48 const SDK_PROFILE_NOT_SUBMITTED_REASON: &str = 49 "profile publish is not part of SDK farm.publish.v1; profile draft was not submitted"; 50 51 static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0); 52 53 pub fn init(config: &RuntimeConfig, args: &FarmCreateArgs) -> Result<FarmSetupView, RuntimeError> { 54 let scope = scope_from_arg(args.scope); 55 let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; 56 let Some(selected_account) = selected_account_for_draft(config)? else { 57 return Ok(missing_selected_account_setup_view()); 58 }; 59 let existing = farm_config::load(config, Some(resolved_scope))?; 60 let document = init_document(resolved_scope, &selected_account, existing.as_ref(), args)?; 61 save_draft_view( 62 "saved", 63 resolved_scope, 64 &selected_account, 65 &document, 66 Some("The farm draft is local until you publish it.".to_owned()), 67 farm_setup_actions(config, &document, Some(&selected_account)), 68 config, 69 ) 70 } 71 72 pub fn init_preflight( 73 config: &RuntimeConfig, 74 args: &FarmCreateArgs, 75 ) -> Result<FarmSetupView, RuntimeError> { 76 let scope = scope_from_arg(args.scope); 77 let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; 78 let Some(selected_account) = selected_account_for_draft(config)? else { 79 return Ok(missing_selected_account_setup_view()); 80 }; 81 let existing = farm_config::load(config, Some(resolved_scope))?; 82 let document = init_document(resolved_scope, &selected_account, existing.as_ref(), args)?; 83 let path = farm_config::config_path(&config.paths, resolved_scope)?; 84 Ok(FarmSetupView { 85 state: "dry_run".to_owned(), 86 source: FARM_CONFIG_SOURCE.to_owned(), 87 config: Some(summary_view( 88 resolved_scope, 89 path.display().to_string(), 90 &document, 91 Some( 92 selected_account 93 .record 94 .public_identity 95 .public_key_hex 96 .as_str(), 97 ), 98 )), 99 reason: Some("dry run requested; farm draft was not written".to_owned()), 100 actions: farm_setup_actions(config, &document, Some(&selected_account)), 101 }) 102 } 103 104 pub fn rebind( 105 config: &RuntimeConfig, 106 args: &FarmRebindArgs, 107 ) -> Result<FarmRebindView, RuntimeError> { 108 rebind_inner(config, args, false) 109 } 110 111 pub fn rebind_preflight( 112 config: &RuntimeConfig, 113 args: &FarmRebindArgs, 114 ) -> Result<FarmRebindView, RuntimeError> { 115 rebind_inner(config, args, true) 116 } 117 118 fn rebind_inner( 119 config: &RuntimeConfig, 120 args: &FarmRebindArgs, 121 dry_run: bool, 122 ) -> Result<FarmRebindView, RuntimeError> { 123 let scope = scope_from_arg(args.scope); 124 let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; 125 let path = farm_config::config_path(&config.paths, resolved_scope)?; 126 let Some(resolved) = farm_config::load(config, Some(resolved_scope))? else { 127 return Ok(FarmRebindView { 128 state: "unconfigured".to_owned(), 129 source: FARM_CONFIG_SOURCE.to_owned(), 130 scope: resolved_scope.as_str().to_owned(), 131 path: path.display().to_string(), 132 config_present: false, 133 dry_run, 134 seller_actor_source: FARM_SELLER_ACTOR_SOURCE.to_owned(), 135 from_seller_account_id: None, 136 from_seller_pubkey: None, 137 to_seller_account_id: None, 138 to_seller_pubkey: None, 139 seller_pubkey_changed: None, 140 publication_state_action: None, 141 config: None, 142 reason: Some(format!("no farm config found at {}", path.display())), 143 actions: vec!["radroots farm create".to_owned()], 144 }); 145 }; 146 147 let from_account = configured_account(config, &resolved.document.selection.account)?; 148 let from_seller_pubkey = from_account 149 .as_ref() 150 .map(|account| account.record.public_identity.public_key_hex.clone()); 151 let target_account = account::resolve_account_selector(config, args.selector.as_str()) 152 .map_err(|error| farm_rebind_selector_error(args.selector.as_str(), error))?; 153 let to_seller_pubkey = target_account.record.public_identity.public_key_hex.clone(); 154 let seller_pubkey_changed = from_seller_pubkey 155 .as_deref() 156 .is_none_or(|pubkey| !pubkey.eq_ignore_ascii_case(to_seller_pubkey.as_str())); 157 let publication_state_action = if seller_pubkey_changed { 158 "cleared" 159 } else { 160 "preserved" 161 }; 162 let mut document = resolved.document.clone(); 163 document.selection.account = target_account.record.account_id.to_string(); 164 if seller_pubkey_changed { 165 document.publication = FarmPublicationStatus::default(); 166 } 167 let written_path = if dry_run { 168 resolved.path.clone() 169 } else { 170 let written_path = farm_config::write(&config.paths, resolved.scope, &document)?; 171 append_farm_local_work( 172 config, 173 resolved.scope, 174 written_path.display().to_string(), 175 &document, 176 Some(to_seller_pubkey.as_str()), 177 )?; 178 written_path 179 }; 180 let state = if dry_run { "dry_run" } else { "rebound" }; 181 182 Ok(FarmRebindView { 183 state: state.to_owned(), 184 source: FARM_CONFIG_SOURCE.to_owned(), 185 scope: resolved.scope.as_str().to_owned(), 186 path: written_path.display().to_string(), 187 config_present: true, 188 dry_run, 189 seller_actor_source: FARM_SELLER_ACTOR_SOURCE.to_owned(), 190 from_seller_account_id: Some(resolved.document.selection.account.clone()), 191 from_seller_pubkey, 192 to_seller_account_id: Some(target_account.record.account_id.to_string()), 193 to_seller_pubkey: Some(to_seller_pubkey.clone()), 194 seller_pubkey_changed: Some(seller_pubkey_changed), 195 publication_state_action: Some(publication_state_action.to_owned()), 196 config: Some(summary_view( 197 resolved.scope, 198 written_path.display().to_string(), 199 &document, 200 Some(to_seller_pubkey.as_str()), 201 )), 202 reason: Some(if dry_run { 203 "dry run requested; farm seller binding was not written".to_owned() 204 } else { 205 "farm seller binding updated".to_owned() 206 }), 207 actions: if dry_run { 208 vec![format!( 209 "radroots --approval-token approve farm rebind {}", 210 args.selector 211 )] 212 } else { 213 vec!["radroots farm readiness check".to_owned()] 214 }, 215 }) 216 } 217 218 fn farm_rebind_selector_error(selector: &str, error: RuntimeError) -> RuntimeError { 219 match error { 220 RuntimeError::Account(account::AccountRuntimeFailure::Unresolved(issue)) => { 221 account::AccountRuntimeFailure::unresolved_with_detail( 222 issue.message().to_owned(), 223 json!({ 224 "seller_actor_source": FARM_SELLER_ACTOR_SOURCE, 225 "selector": selector, 226 "actions": account_recovery_actions(), 227 }), 228 ) 229 .into() 230 } 231 other => other, 232 } 233 } 234 235 pub fn set(config: &RuntimeConfig, args: &FarmUpdateArgs) -> Result<FarmSetView, RuntimeError> { 236 let scope = scope_from_arg(args.scope); 237 let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; 238 let path = farm_config::config_path(&config.paths, resolved_scope)?; 239 let Some(mut resolved) = farm_config::load(config, Some(resolved_scope))? else { 240 return Ok(FarmSetView { 241 state: "unconfigured".to_owned(), 242 source: FARM_CONFIG_SOURCE.to_owned(), 243 field: human_field_name(args.field).to_owned(), 244 value: human_field_value(args.field, args.value.join(" ").trim()).to_owned(), 245 config: None, 246 reason: Some(format!("no farm draft found at {}", path.display())), 247 actions: vec!["radroots farm create".to_owned()], 248 }); 249 }; 250 251 let raw_value = args.value.join(" "); 252 let field_value = required_text(raw_value.as_str(), "farm set value")?; 253 apply_field_update(&mut resolved.document, args.field, field_value.as_str())?; 254 let written_path = farm_config::write(&config.paths, resolved.scope, &resolved.document)?; 255 let configured_account = configured_account(config, &resolved.document.selection.account)?; 256 let account_pubkey = configured_account 257 .as_ref() 258 .map(|account| account.record.public_identity.public_key_hex.as_str()); 259 append_farm_local_work( 260 config, 261 resolved.scope, 262 written_path.display().to_string(), 263 &resolved.document, 264 account_pubkey, 265 )?; 266 let reason = if configured_account.is_none() { 267 Some(missing_farm_bound_seller_reason( 268 resolved.document.selection.account.as_str(), 269 )) 270 } else { 271 None 272 }; 273 274 Ok(FarmSetView { 275 state: "updated".to_owned(), 276 source: FARM_CONFIG_SOURCE.to_owned(), 277 field: human_field_name(args.field).to_owned(), 278 value: human_field_value(args.field, field_value.as_str()).to_owned(), 279 config: Some(summary_view( 280 resolved.scope, 281 written_path.display().to_string(), 282 &resolved.document, 283 account_pubkey, 284 )), 285 reason, 286 actions: farm_update_actions(config, &resolved.document, configured_account.as_ref()), 287 }) 288 } 289 290 pub fn set_preflight( 291 config: &RuntimeConfig, 292 args: &FarmUpdateArgs, 293 ) -> Result<FarmSetView, RuntimeError> { 294 let scope = scope_from_arg(args.scope); 295 let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; 296 let path = farm_config::config_path(&config.paths, resolved_scope)?; 297 let Some(mut resolved) = farm_config::load(config, Some(resolved_scope))? else { 298 return Ok(FarmSetView { 299 state: "unconfigured".to_owned(), 300 source: FARM_CONFIG_SOURCE.to_owned(), 301 field: human_field_name(args.field).to_owned(), 302 value: human_field_value(args.field, args.value.join(" ").trim()).to_owned(), 303 config: None, 304 reason: Some(format!("no farm draft found at {}", path.display())), 305 actions: vec!["radroots farm create".to_owned()], 306 }); 307 }; 308 309 let raw_value = args.value.join(" "); 310 let field_value = required_text(raw_value.as_str(), "farm set value")?; 311 apply_field_update(&mut resolved.document, args.field, field_value.as_str())?; 312 let configured_account = configured_account(config, &resolved.document.selection.account)?; 313 let account_pubkey = configured_account 314 .as_ref() 315 .map(|account| account.record.public_identity.public_key_hex.as_str()); 316 let reason = if configured_account.is_none() { 317 Some(format!( 318 "dry run requested; farm draft was not written; {}", 319 missing_farm_bound_seller_reason(resolved.document.selection.account.as_str()) 320 )) 321 } else { 322 Some("dry run requested; farm draft was not written".to_owned()) 323 }; 324 325 Ok(FarmSetView { 326 state: "dry_run".to_owned(), 327 source: FARM_CONFIG_SOURCE.to_owned(), 328 field: human_field_name(args.field).to_owned(), 329 value: human_field_value(args.field, field_value.as_str()).to_owned(), 330 config: Some(summary_view( 331 resolved.scope, 332 path.display().to_string(), 333 &resolved.document, 334 account_pubkey, 335 )), 336 reason, 337 actions: farm_update_actions(config, &resolved.document, configured_account.as_ref()), 338 }) 339 } 340 341 pub fn status( 342 config: &RuntimeConfig, 343 args: &FarmScopedArgs, 344 ) -> Result<FarmStatusView, RuntimeError> { 345 let scope = scope_from_arg(args.scope); 346 let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; 347 let path = farm_config::config_path(&config.paths, resolved_scope)?; 348 let Some(resolved) = farm_config::load(config, Some(resolved_scope))? else { 349 return Ok(FarmStatusView { 350 state: "unconfigured".to_owned(), 351 source: FARM_CONFIG_SOURCE.to_owned(), 352 scope: resolved_scope.as_str().to_owned(), 353 path: path.display().to_string(), 354 config_present: false, 355 config_valid: false, 356 account_state: "not_checked".to_owned(), 357 listing_defaults_state: "missing".to_owned(), 358 publish_transport: config.publish.transport.as_str().to_owned(), 359 publish_state: "not_checked".to_owned(), 360 publish_executable: false, 361 publish_reason: None, 362 config: None, 363 missing: vec!["Farm draft".to_owned()], 364 reason: Some(format!("no farm config found at {}", path.display())), 365 actions: vec!["radroots farm create".to_owned()], 366 }); 367 }; 368 369 let account = configured_account(config, &resolved.document.selection.account)?; 370 let draft_missing = farm_config::missing_fields(&resolved.document); 371 let account_state = if account.is_some() { 372 "ready" 373 } else { 374 "missing" 375 }; 376 let listing_defaults_state = if missing_blocks_listing_defaults(draft_missing.as_slice()) { 377 "missing" 378 } else { 379 "ready" 380 }; 381 let publish = account 382 .as_ref() 383 .filter(|_| draft_missing.is_empty()) 384 .map(|account| farm_publish_readiness(config, account)) 385 .unwrap_or_else(FarmPublishReadiness::not_checked); 386 let state = if account.is_some() && draft_missing.is_empty() && publish.executable { 387 "ready" 388 } else { 389 "unconfigured" 390 }; 391 let reason = if account.is_none() { 392 Some(format!( 393 "farm config account `{}` is not present in the local account store", 394 resolved.document.selection.account 395 )) 396 } else if !draft_missing.is_empty() { 397 Some("farm draft is missing required fields".to_owned()) 398 } else { 399 publish.reason.clone() 400 }; 401 let mut actions = Vec::new(); 402 if account.is_none() { 403 actions.push("radroots account import <path>".to_owned()); 404 actions.push("radroots farm rebind <selector>".to_owned()); 405 } else if draft_missing.is_empty() { 406 actions.extend(publish.actions.clone()); 407 } else { 408 actions.extend(missing_field_actions(draft_missing.as_slice())); 409 } 410 let account_pubkey = account 411 .as_ref() 412 .map(|account| account.record.public_identity.public_key_hex.as_str()); 413 414 Ok(FarmStatusView { 415 state: state.to_owned(), 416 source: FARM_CONFIG_SOURCE.to_owned(), 417 scope: resolved.scope.as_str().to_owned(), 418 path: resolved.path.display().to_string(), 419 config_present: true, 420 config_valid: true, 421 account_state: account_state.to_owned(), 422 listing_defaults_state: listing_defaults_state.to_owned(), 423 publish_transport: config.publish.transport.as_str().to_owned(), 424 publish_state: publish.state.to_owned(), 425 publish_executable: publish.executable, 426 publish_reason: publish.reason, 427 config: Some(summary_view( 428 resolved.scope, 429 resolved.path.display().to_string(), 430 &resolved.document, 431 account_pubkey, 432 )), 433 missing: if account.is_none() { 434 vec!["Farm-bound seller account".to_owned()] 435 } else { 436 let mut missing = missing_field_labels(draft_missing.as_slice()); 437 missing.extend(publish.missing); 438 missing 439 }, 440 reason, 441 actions, 442 }) 443 } 444 445 pub fn get(config: &RuntimeConfig, args: &FarmScopedArgs) -> Result<FarmGetView, RuntimeError> { 446 let scope = scope_from_arg(args.scope); 447 let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; 448 let path = farm_config::config_path(&config.paths, resolved_scope)?; 449 let Some(resolved) = farm_config::load(config, Some(resolved_scope))? else { 450 return Ok(FarmGetView { 451 state: "unconfigured".to_owned(), 452 source: FARM_CONFIG_SOURCE.to_owned(), 453 scope: resolved_scope.as_str().to_owned(), 454 path: path.display().to_string(), 455 config_present: false, 456 document: None, 457 reason: Some(format!("no farm config found at {}", path.display())), 458 actions: vec!["radroots farm create".to_owned()], 459 }); 460 }; 461 462 Ok(FarmGetView { 463 state: "ready".to_owned(), 464 source: FARM_CONFIG_SOURCE.to_owned(), 465 scope: resolved.scope.as_str().to_owned(), 466 path: resolved.path.display().to_string(), 467 config_present: true, 468 document: Some(document_view(&resolved.document)), 469 reason: None, 470 actions: Vec::new(), 471 }) 472 } 473 474 #[derive(Debug, Clone)] 475 struct FarmPublishReadiness { 476 state: &'static str, 477 executable: bool, 478 reason: Option<String>, 479 missing: Vec<String>, 480 actions: Vec<String>, 481 } 482 483 impl FarmPublishReadiness { 484 fn not_checked() -> Self { 485 Self { 486 state: "not_checked", 487 executable: false, 488 reason: None, 489 missing: Vec::new(), 490 actions: Vec::new(), 491 } 492 } 493 } 494 495 fn farm_publish_readiness( 496 config: &RuntimeConfig, 497 account: &AccountRecordView, 498 ) -> FarmPublishReadiness { 499 relay_farm_publish_readiness(config, account) 500 } 501 502 fn relay_farm_publish_readiness( 503 config: &RuntimeConfig, 504 account: &AccountRecordView, 505 ) -> FarmPublishReadiness { 506 if matches!(config.publish.transport, PublishTransport::DirectNostrRelay) 507 && config.relay.urls.is_empty() 508 { 509 return FarmPublishReadiness { 510 state: "unconfigured", 511 executable: false, 512 reason: Some( 513 "direct_nostr_relay farm publish requires at least one configured relay".to_owned(), 514 ), 515 missing: vec!["Configured relay".to_owned()], 516 actions: vec!["radroots --relay wss://relay.example.com farm publish".to_owned()], 517 }; 518 } 519 520 if matches!(config.signer.backend, SignerBackend::Myc) { 521 if let Err(error) = validate_configured_signer_for_actor( 522 config, 523 Some(account.record.account_id.as_str()), 524 account.record.public_identity.public_key_hex.as_str(), 525 "farm seller", 526 ) { 527 return FarmPublishReadiness { 528 state: "unconfigured", 529 executable: false, 530 reason: Some(error.to_string()), 531 missing: vec!["Remote signer binding".to_owned()], 532 actions: vec!["radroots signer status get".to_owned()], 533 }; 534 } 535 return FarmPublishReadiness { 536 state: "ready", 537 executable: true, 538 reason: None, 539 missing: Vec::new(), 540 actions: Vec::new(), 541 }; 542 } 543 544 if !account.write_capable { 545 return FarmPublishReadiness { 546 state: "unconfigured", 547 executable: false, 548 reason: Some( 549 account::AccountRuntimeFailure::watch_only(&account.record.account_id).to_string(), 550 ), 551 missing: vec!["Write-capable farm-bound seller account".to_owned()], 552 actions: vec![format!( 553 "radroots account attach-secret {} <path>", 554 account.record.account_id 555 )], 556 }; 557 } 558 559 FarmPublishReadiness { 560 state: "ready", 561 executable: true, 562 reason: None, 563 missing: Vec::new(), 564 actions: vec!["radroots farm publish".to_owned()], 565 } 566 } 567 568 pub fn publish( 569 config: &RuntimeConfig, 570 args: &FarmPublishArgs, 571 ) -> Result<FarmPublishView, CliSdkAdapterError> { 572 let scope = scope_from_arg(args.scope); 573 let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; 574 let path = farm_config::config_path(&config.paths, resolved_scope)?; 575 let Some(resolved) = farm_config::load(config, Some(resolved_scope))? else { 576 return Ok(missing_publish_view( 577 config, 578 resolved_scope, 579 path.display().to_string(), 580 args, 581 format!("no farm config found at {}", path.display()), 582 vec!["Farm draft".to_owned()], 583 vec!["radroots farm create".to_owned()], 584 config.output.dry_run, 585 false, 586 String::new(), 587 String::new(), 588 String::new(), 589 )); 590 }; 591 592 let Some(account) = configured_account(config, &resolved.document.selection.account)? else { 593 return Ok(missing_publish_view( 594 config, 595 resolved.scope, 596 resolved.path.display().to_string(), 597 args, 598 format!( 599 "farm config account `{}` is not present in the local account store", 600 resolved.document.selection.account 601 ), 602 vec!["Farm-bound seller account".to_owned()], 603 vec![ 604 "radroots account import <path>".to_owned(), 605 "radroots farm rebind <selector>".to_owned(), 606 ], 607 config.output.dry_run, 608 true, 609 resolved.document.selection.account.clone(), 610 String::new(), 611 resolved.document.selection.farm_d_tag.clone(), 612 )); 613 }; 614 let draft_missing = farm_config::missing_fields(&resolved.document); 615 if !draft_missing.is_empty() { 616 return Ok(missing_publish_view( 617 config, 618 resolved.scope, 619 resolved.path.display().to_string(), 620 args, 621 "farm draft is missing required fields".to_owned(), 622 missing_field_labels(draft_missing.as_slice()), 623 missing_field_actions(draft_missing.as_slice()), 624 config.output.dry_run, 625 true, 626 resolved.document.selection.account.clone(), 627 account.record.public_identity.public_key_hex.clone(), 628 resolved.document.selection.farm_d_tag.clone(), 629 )); 630 } 631 let account_pubkey = account.record.public_identity.public_key_hex.clone(); 632 let previews = build_publish_previews(&resolved.document, account_pubkey.as_str())?; 633 let profile_idempotency_key = component_idempotency_key(args, "profile")?; 634 let farm_idempotency_key = component_idempotency_key(args, "farm")?; 635 636 publish_via_sdk( 637 config, 638 args, 639 resolved, 640 account_pubkey, 641 previews, 642 profile_idempotency_key, 643 farm_idempotency_key, 644 ) 645 } 646 647 fn publish_via_sdk( 648 config: &RuntimeConfig, 649 args: &FarmPublishArgs, 650 mut resolved: ResolvedFarmConfig, 651 account_pubkey: String, 652 previews: FarmPublishPreviews, 653 profile_idempotency_key: Option<String>, 654 farm_idempotency_key: Option<String>, 655 ) -> Result<FarmPublishView, CliSdkAdapterError> { 656 let input = sdk_farm_publish_input(&resolved, account_pubkey.as_str())?; 657 if config.output.dry_run { 658 if let Err(error) = validate_configured_signer_for_actor( 659 config, 660 Some(resolved.document.selection.account.as_str()), 661 account_pubkey.as_str(), 662 "farm seller", 663 ) { 664 let binding_error = ActorWriteBindingError::from_runtime(error); 665 return match binding_error { 666 ActorWriteBindingError::Account(failure) => Err(RuntimeError::from(failure).into()), 667 error => Ok(binding_error_publish_view( 668 config, 669 args, 670 &resolved, 671 &account_pubkey, 672 previews, 673 profile_idempotency_key, 674 farm_idempotency_key, 675 error, 676 )), 677 }; 678 } 679 680 let session = CliSdkSession::connect_memory(config)?; 681 let plan = session 682 .sdk() 683 .farms() 684 .prepare_publish(FarmPreparePublishRequest::new(input.actor, input.farm))?; 685 return Ok(sdk_prepared_publish_view( 686 config, 687 args, 688 &resolved, 689 account_pubkey.as_str(), 690 previews, 691 profile_idempotency_key, 692 farm_idempotency_key, 693 plan, 694 )); 695 } 696 697 let session = CliSdkSession::connect_for_actor( 698 config, 699 Some(resolved.document.selection.account.as_str()), 700 account_pubkey.as_str(), 701 "farm seller", 702 )?; 703 let mut request = 704 FarmEnqueuePublishRequest::new(input.actor, input.farm, sdk_relay_target_policy(config)); 705 if let Some(idempotency_key) = farm_idempotency_key.as_deref() { 706 request = request.try_with_idempotency_key(idempotency_key)?; 707 } 708 let enqueue = session.block_on(session.sdk().farms().enqueue_publish(request))?; 709 let push = session.block_on( 710 session.sdk().sync().push_outbox( 711 PushOutboxRequest::new() 712 .with_limit(1) 713 .with_relay_url_policy(sdk_relay_url_policy(config)), 714 ), 715 )?; 716 let view = sdk_enqueued_publish_view( 717 config, 718 args, 719 &resolved, 720 account_pubkey.as_str(), 721 previews, 722 profile_idempotency_key, 723 farm_idempotency_key, 724 &enqueue, 725 &push, 726 ); 727 if view.farm.state == "published" { 728 persist_farm_publication( 729 config, 730 &mut resolved, 731 enqueue.signed_event_id.as_str().to_owned(), 732 )?; 733 } 734 Ok(view) 735 } 736 737 #[derive(Debug, Clone)] 738 struct SdkFarmPublishInput { 739 actor: RadrootsActorContext, 740 farm: RadrootsFarm, 741 } 742 743 #[derive(Debug, Clone)] 744 struct FarmPublishPreviews { 745 profile: FarmPublishEventDraft, 746 } 747 748 #[derive(Debug, Clone)] 749 struct FarmPublishEventDraft { 750 event: FarmPublishEventView, 751 } 752 753 fn missing_publish_view( 754 config: &RuntimeConfig, 755 scope: FarmConfigScope, 756 path: String, 757 args: &FarmPublishArgs, 758 reason: String, 759 missing: Vec<String>, 760 actions: Vec<String>, 761 dry_run: bool, 762 config_present: bool, 763 seller_account_id: String, 764 seller_pubkey: String, 765 farm_d_tag: String, 766 ) -> FarmPublishView { 767 FarmPublishView { 768 state: "unconfigured".to_owned(), 769 source: farm_write_source(config).to_owned(), 770 scope: scope.as_str().to_owned(), 771 path, 772 config_present, 773 dry_run, 774 seller_account_id, 775 seller_pubkey, 776 seller_actor_source: FARM_SELLER_ACTOR_SOURCE.to_owned(), 777 farm_d_tag, 778 profile: not_submitted_component( 779 profile_publish_rpc_method(config), 780 KIND_PROFILE, 781 args, 782 None, 783 None, 784 ), 785 farm: not_submitted_component(farm_publish_rpc_method(config), KIND_FARM, args, None, None), 786 local_replica: Vec::new(), 787 missing, 788 reason: Some(reason), 789 actions, 790 } 791 } 792 793 fn base_publish_view( 794 state: &str, 795 config: &RuntimeConfig, 796 _args: &FarmPublishArgs, 797 resolved: &ResolvedFarmConfig, 798 account_pubkey: &str, 799 profile: FarmPublishComponentView, 800 farm: FarmPublishComponentView, 801 reason: Option<String>, 802 actions: Vec<String>, 803 ) -> FarmPublishView { 804 FarmPublishView { 805 state: state.to_owned(), 806 source: farm_write_source(config).to_owned(), 807 scope: resolved.scope.as_str().to_owned(), 808 path: resolved.path.display().to_string(), 809 config_present: true, 810 dry_run: config.output.dry_run, 811 seller_account_id: resolved.document.selection.account.clone(), 812 seller_pubkey: account_pubkey.to_owned(), 813 seller_actor_source: FARM_SELLER_ACTOR_SOURCE.to_owned(), 814 farm_d_tag: resolved.document.selection.farm_d_tag.clone(), 815 profile, 816 farm, 817 local_replica: Vec::new(), 818 missing: Vec::new(), 819 reason, 820 actions, 821 } 822 } 823 824 fn build_publish_previews( 825 document: &FarmConfigDocument, 826 account_pubkey: &str, 827 ) -> Result<FarmPublishPreviews, RuntimeError> { 828 let profile_parts = 829 to_wire_parts_with_profile_type(&document.profile, Some(RadrootsProfileType::Farm)) 830 .map_err(|error| RuntimeError::Config(format!("invalid farm profile: {error}")))?; 831 832 Ok(FarmPublishPreviews { 833 profile: FarmPublishEventDraft { 834 event: FarmPublishEventView { 835 kind: profile_parts.kind, 836 author: account_pubkey.to_owned(), 837 content: profile_parts.content.clone(), 838 tags: profile_parts.tags.clone(), 839 event_id: None, 840 event_addr: None, 841 }, 842 }, 843 }) 844 } 845 846 fn component_idempotency_key( 847 args: &FarmPublishArgs, 848 component: &str, 849 ) -> Result<Option<String>, RuntimeError> { 850 args.idempotency_key 851 .as_deref() 852 .map(|value| { 853 required_text(value, "idempotency_key").map(|key| format!("{key}:{component}")) 854 }) 855 .transpose() 856 } 857 858 fn preview_component( 859 rpc_method: &str, 860 event_kind: u32, 861 idempotency_key: Option<String>, 862 args: &FarmPublishArgs, 863 event: Option<FarmPublishEventView>, 864 ) -> FarmPublishComponentView { 865 FarmPublishComponentView { 866 state: if event.is_some() { 867 "not_submitted".to_owned() 868 } else { 869 "unconfigured".to_owned() 870 }, 871 rpc_method: rpc_method.to_owned(), 872 event_kind, 873 deduplicated: false, 874 target_relays: Vec::new(), 875 connected_relays: Vec::new(), 876 acknowledged_relays: Vec::new(), 877 failed_relays: Vec::new(), 878 job_id: None, 879 job_status: None, 880 signer_mode: None, 881 event_id: None, 882 event_addr: event.as_ref().and_then(|event| event.event_addr.clone()), 883 idempotency_key: idempotency_key.clone(), 884 reason: Some("not submitted".to_owned()), 885 job: None, 886 event: args.print_event.then_some(event).flatten(), 887 } 888 } 889 890 fn not_submitted_component( 891 rpc_method: &str, 892 event_kind: u32, 893 args: &FarmPublishArgs, 894 idempotency_key: Option<String>, 895 event: Option<FarmPublishEventView>, 896 ) -> FarmPublishComponentView { 897 preview_component(rpc_method, event_kind, idempotency_key, args, event) 898 } 899 900 fn binding_error_publish_view( 901 config: &RuntimeConfig, 902 args: &FarmPublishArgs, 903 resolved: &ResolvedFarmConfig, 904 account_pubkey: &str, 905 previews: FarmPublishPreviews, 906 profile_idempotency_key: Option<String>, 907 farm_idempotency_key: Option<String>, 908 error: ActorWriteBindingError, 909 ) -> FarmPublishView { 910 let reason = error.reason(); 911 let state = "unconfigured".to_owned(); 912 let actions = vec!["run radroots signer status get".to_owned()]; 913 base_publish_view( 914 state.as_str(), 915 config, 916 args, 917 resolved, 918 account_pubkey, 919 FarmPublishComponentView { 920 state: state.clone(), 921 reason: Some(reason.clone()), 922 ..profile_not_submitted_component( 923 profile_idempotency_key, 924 args, 925 Some(previews.profile.event), 926 ) 927 }, 928 FarmPublishComponentView { 929 state: state.clone(), 930 reason: Some(reason.clone()), 931 ..preview_component( 932 farm_publish_rpc_method(config), 933 KIND_FARM, 934 farm_idempotency_key, 935 args, 936 None, 937 ) 938 }, 939 Some(reason), 940 actions, 941 ) 942 } 943 944 fn sdk_farm_publish_input( 945 resolved: &ResolvedFarmConfig, 946 account_pubkey: &str, 947 ) -> Result<SdkFarmPublishInput, RuntimeError> { 948 let actor = RadrootsActorContext::local_account( 949 account_pubkey, 950 resolved.document.selection.account.clone(), 951 [RadrootsActorRole::Farmer], 952 ) 953 .map_err(|error| RuntimeError::Config(format!("invalid farm SDK actor: {error}")))?; 954 Ok(SdkFarmPublishInput { 955 actor, 956 farm: resolved.document.farm.clone(), 957 }) 958 } 959 960 fn sdk_prepared_publish_view( 961 config: &RuntimeConfig, 962 args: &FarmPublishArgs, 963 resolved: &ResolvedFarmConfig, 964 account_pubkey: &str, 965 previews: FarmPublishPreviews, 966 profile_idempotency_key: Option<String>, 967 farm_idempotency_key: Option<String>, 968 plan: FarmPublishPlan, 969 ) -> FarmPublishView { 970 base_publish_view( 971 "dry_run", 972 config, 973 args, 974 resolved, 975 account_pubkey, 976 profile_not_submitted_component( 977 profile_idempotency_key, 978 args, 979 Some(previews.profile.event), 980 ), 981 FarmPublishComponentView { 982 state: "not_submitted".to_owned(), 983 reason: Some("dry run requested; SDK enqueue and relay push skipped".to_owned()), 984 signer_mode: Some(config.signer.backend.as_str().to_owned()), 985 event_id: Some(plan.expected_event_id.as_str().to_owned()), 986 event_addr: Some(plan.farm_addr.as_str().to_owned()), 987 event: args.print_event.then_some(sdk_plan_event_view(&plan)), 988 ..preview_component( 989 farm_publish_rpc_method(config), 990 KIND_FARM, 991 farm_idempotency_key, 992 args, 993 None, 994 ) 995 }, 996 Some("dry run requested; SDK enqueue and relay push skipped".to_owned()), 997 vec!["radroots farm publish".to_owned()], 998 ) 999 } 1000 1001 fn sdk_enqueued_publish_view( 1002 config: &RuntimeConfig, 1003 args: &FarmPublishArgs, 1004 resolved: &ResolvedFarmConfig, 1005 account_pubkey: &str, 1006 previews: FarmPublishPreviews, 1007 profile_idempotency_key: Option<String>, 1008 farm_idempotency_key: Option<String>, 1009 enqueue: &FarmEnqueueReceipt, 1010 push: &PushOutboxReceipt, 1011 ) -> FarmPublishView { 1012 let push_event = sdk_push_event_for_farm(enqueue, push); 1013 let state = sdk_publish_state(push_event); 1014 let view_state = state.clone(); 1015 let reason = sdk_publish_reason(push_event); 1016 base_publish_view( 1017 view_state.as_str(), 1018 config, 1019 args, 1020 resolved, 1021 account_pubkey, 1022 profile_not_submitted_component( 1023 profile_idempotency_key, 1024 args, 1025 Some(previews.profile.event), 1026 ), 1027 FarmPublishComponentView { 1028 state, 1029 deduplicated: matches!(enqueue.state, SdkMutationState::AlreadyQueued), 1030 target_relays: push_event 1031 .map(sdk_push_target_relays) 1032 .unwrap_or_else(|| config.relay.urls.clone()), 1033 connected_relays: push_event 1034 .map(sdk_push_connected_relays) 1035 .unwrap_or_default(), 1036 acknowledged_relays: push_event 1037 .map(sdk_push_acknowledged_relays) 1038 .unwrap_or_default(), 1039 failed_relays: push_event.map(sdk_push_failed_relays).unwrap_or_default(), 1040 signer_mode: Some(config.signer.backend.as_str().to_owned()), 1041 event_id: Some(enqueue.signed_event_id.as_str().to_owned()), 1042 event_addr: Some(enqueue.farm_addr.as_str().to_owned()), 1043 idempotency_key: farm_idempotency_key, 1044 reason: sdk_publish_reason(push_event), 1045 ..preview_component(farm_publish_rpc_method(config), KIND_FARM, None, args, None) 1046 }, 1047 reason, 1048 sdk_publish_actions(push_event), 1049 ) 1050 } 1051 1052 fn sdk_plan_event_view(plan: &FarmPublishPlan) -> FarmPublishEventView { 1053 FarmPublishEventView { 1054 kind: plan.frozen_draft.kind, 1055 author: plan.frozen_draft.expected_pubkey.clone(), 1056 content: plan.frozen_draft.content.clone(), 1057 tags: plan.frozen_draft.tags.clone(), 1058 event_id: Some(plan.expected_event_id.as_str().to_owned()), 1059 event_addr: Some(plan.farm_addr.as_str().to_owned()), 1060 } 1061 } 1062 1063 fn sdk_push_event_for_farm<'a>( 1064 enqueue: &FarmEnqueueReceipt, 1065 push: &'a PushOutboxReceipt, 1066 ) -> Option<&'a PushOutboxEventReceipt> { 1067 push.events 1068 .iter() 1069 .find(|event| event.event_id == enqueue.signed_event_id) 1070 } 1071 1072 fn sdk_publish_state(push_event: Option<&PushOutboxEventReceipt>) -> String { 1073 match push_event.map(|event| event.final_state) { 1074 Some(PushOutboxEventState::Published) => "published", 1075 Some(PushOutboxEventState::PublishRetryable | PushOutboxEventState::FailedTerminal) => { 1076 "unavailable" 1077 } 1078 Some(_) | None => "queued", 1079 } 1080 .to_owned() 1081 } 1082 1083 fn sdk_publish_reason(push_event: Option<&PushOutboxEventReceipt>) -> Option<String> { 1084 match push_event.map(|event| event.final_state) { 1085 Some(PushOutboxEventState::Published) => None, 1086 Some(PushOutboxEventState::PublishRetryable) => Some( 1087 "SDK relay publish did not reach accepted quorum; outbox event remains retryable" 1088 .to_owned(), 1089 ), 1090 Some(PushOutboxEventState::FailedTerminal) => { 1091 Some("SDK relay publish failed terminally".to_owned()) 1092 } 1093 Some(state) => Some(format!("SDK relay push left event in state `{state:?}`")), 1094 None => Some( 1095 "farm publish queued in SDK outbox; no ready SDK outbox event was pushed".to_owned(), 1096 ), 1097 } 1098 } 1099 1100 fn sdk_publish_actions(push_event: Option<&PushOutboxEventReceipt>) -> Vec<String> { 1101 if !matches!( 1102 push_event.map(|event| event.final_state), 1103 Some(PushOutboxEventState::Published) 1104 ) { 1105 return vec!["radroots sync push".to_owned()]; 1106 } 1107 Vec::new() 1108 } 1109 1110 fn sdk_push_target_relays(event: &PushOutboxEventReceipt) -> Vec<String> { 1111 event 1112 .relays 1113 .iter() 1114 .map(|relay| relay.relay_url.clone()) 1115 .collect() 1116 } 1117 1118 fn sdk_push_connected_relays(event: &PushOutboxEventReceipt) -> Vec<String> { 1119 event 1120 .relays 1121 .iter() 1122 .filter(|relay| relay.attempted) 1123 .map(|relay| relay.relay_url.clone()) 1124 .collect() 1125 } 1126 1127 fn sdk_push_acknowledged_relays(event: &PushOutboxEventReceipt) -> Vec<String> { 1128 event 1129 .relays 1130 .iter() 1131 .filter(|relay| { 1132 matches!( 1133 relay.outcome_kind, 1134 PushOutboxRelayOutcomeKind::Accepted 1135 | PushOutboxRelayOutcomeKind::DuplicateAccepted 1136 ) 1137 }) 1138 .map(|relay| relay.relay_url.clone()) 1139 .collect() 1140 } 1141 1142 fn sdk_push_failed_relays(event: &PushOutboxEventReceipt) -> Vec<RelayFailureView> { 1143 event 1144 .relays 1145 .iter() 1146 .filter(|relay| { 1147 !matches!( 1148 relay.outcome_kind, 1149 PushOutboxRelayOutcomeKind::Accepted 1150 | PushOutboxRelayOutcomeKind::DuplicateAccepted 1151 ) 1152 }) 1153 .map(|relay| RelayFailureView { 1154 relay: relay.relay_url.clone(), 1155 reason: relay 1156 .message 1157 .clone() 1158 .unwrap_or_else(|| sdk_relay_outcome_kind(relay.outcome_kind).to_owned()), 1159 }) 1160 .collect() 1161 } 1162 1163 fn sdk_relay_outcome_kind(kind: PushOutboxRelayOutcomeKind) -> &'static str { 1164 match kind { 1165 PushOutboxRelayOutcomeKind::Accepted => "accepted", 1166 PushOutboxRelayOutcomeKind::DuplicateAccepted => "duplicate_accepted", 1167 PushOutboxRelayOutcomeKind::Blocked => "blocked", 1168 PushOutboxRelayOutcomeKind::RateLimited => "rate_limited", 1169 PushOutboxRelayOutcomeKind::Invalid => "invalid", 1170 PushOutboxRelayOutcomeKind::PowRequired => "pow_required", 1171 PushOutboxRelayOutcomeKind::Restricted => "restricted", 1172 PushOutboxRelayOutcomeKind::AuthRequired => "auth_required", 1173 PushOutboxRelayOutcomeKind::Error => "error", 1174 PushOutboxRelayOutcomeKind::Timeout => "timeout", 1175 PushOutboxRelayOutcomeKind::ConnectionFailed => "connection_failed", 1176 PushOutboxRelayOutcomeKind::Unknown => "unknown", 1177 _ => "unknown", 1178 } 1179 } 1180 1181 fn profile_not_submitted_component( 1182 idempotency_key: Option<String>, 1183 args: &FarmPublishArgs, 1184 event: Option<FarmPublishEventView>, 1185 ) -> FarmPublishComponentView { 1186 FarmPublishComponentView { 1187 reason: Some(SDK_PROFILE_NOT_SUBMITTED_REASON.to_owned()), 1188 ..preview_component( 1189 SDK_PROFILE_NOT_SUBMITTED_METHOD, 1190 KIND_PROFILE, 1191 idempotency_key, 1192 args, 1193 event, 1194 ) 1195 } 1196 } 1197 1198 fn persist_farm_publication( 1199 config: &RuntimeConfig, 1200 resolved: &mut ResolvedFarmConfig, 1201 event_id: String, 1202 ) -> Result<(), RuntimeError> { 1203 persist_publication(config, resolved, None, Some(event_id)) 1204 } 1205 1206 fn persist_publication( 1207 config: &RuntimeConfig, 1208 resolved: &mut ResolvedFarmConfig, 1209 profile_event_id: Option<String>, 1210 farm_event_id: Option<String>, 1211 ) -> Result<(), RuntimeError> { 1212 let published_at = now_unix(); 1213 if let Some(event_id) = profile_event_id.and_then(|value| non_empty(value.as_str())) { 1214 resolved.document.publication.profile_event_id = Some(event_id); 1215 resolved.document.publication.profile_published_at = Some(published_at); 1216 } 1217 if let Some(event_id) = farm_event_id.and_then(|value| non_empty(value.as_str())) { 1218 resolved.document.publication.farm_event_id = Some(event_id); 1219 resolved.document.publication.farm_published_at = Some(published_at); 1220 } 1221 farm_config::write(&config.paths, resolved.scope, &resolved.document)?; 1222 Ok(()) 1223 } 1224 1225 fn farm_write_source(config: &RuntimeConfig) -> &'static str { 1226 let _ = config; 1227 SDK_FARM_WRITE_SOURCE 1228 } 1229 1230 fn profile_publish_rpc_method(config: &RuntimeConfig) -> &'static str { 1231 let _ = config; 1232 SDK_PROFILE_NOT_SUBMITTED_METHOD 1233 } 1234 1235 fn farm_publish_rpc_method(config: &RuntimeConfig) -> &'static str { 1236 let _ = config; 1237 SDK_FARM_PUBLISH_METHOD 1238 } 1239 1240 fn selected_account_for_draft( 1241 config: &RuntimeConfig, 1242 ) -> Result<Option<AccountRecordView>, RuntimeError> { 1243 account::resolve_account(config) 1244 } 1245 1246 fn missing_selected_account_setup_view() -> FarmSetupView { 1247 FarmSetupView { 1248 state: "unconfigured".to_owned(), 1249 source: FARM_CONFIG_SOURCE.to_owned(), 1250 config: None, 1251 reason: Some("choose or create an account before setting up your farm".to_owned()), 1252 actions: vec!["radroots account create".to_owned()], 1253 } 1254 } 1255 1256 fn init_document( 1257 scope: FarmConfigScope, 1258 account: &AccountRecordView, 1259 existing: Option<&ResolvedFarmConfig>, 1260 args: &FarmCreateArgs, 1261 ) -> Result<FarmConfigDocument, RuntimeError> { 1262 let existing_document = existing.map(|resolved| &resolved.document); 1263 if let Some(document) = existing_document 1264 && document.selection.account != account.record.account_id.to_string() 1265 { 1266 let message = format!( 1267 "account mismatch: farm config is bound to seller account `{}`; use `radroots farm rebind {}` to change the farm-bound seller account", 1268 document.selection.account, account.record.account_id 1269 ); 1270 return Err(account::AccountRuntimeFailure::mismatch_with_detail( 1271 message, 1272 json!({ 1273 "seller_actor_source": FARM_SELLER_ACTOR_SOURCE, 1274 "farm_bound_seller_account_id": document.selection.account, 1275 "attempted_seller_account_id": account.record.account_id.to_string(), 1276 "actions": [format!("radroots farm rebind {}", account.record.account_id)], 1277 }), 1278 ) 1279 .into()); 1280 } 1281 let farm_d_tag = match args.farm_d_tag.as_deref() { 1282 Some(value) => required_d_tag(value, "farm_d_tag")?, 1283 None => existing_document 1284 .map(|document| document.farm.d_tag.clone()) 1285 .unwrap_or_else(generate_d_tag), 1286 }; 1287 let existing_name = existing_name(existing_document); 1288 let existing_location = existing_location_primary(existing_document); 1289 let existing_city = existing_city(existing_document); 1290 let existing_region = existing_region(existing_document); 1291 let existing_country = existing_country(existing_document); 1292 let existing_delivery = existing_delivery_method(existing_document); 1293 let name = optional_arg_or_existing(args.name.as_ref(), existing_name.as_ref()) 1294 .or_else(|| draft_name_from_account(account)) 1295 .unwrap_or_default(); 1296 let display_name = optional_arg_or_existing( 1297 args.display_name.as_ref(), 1298 existing_document.and_then(|document| document.profile.display_name.as_ref()), 1299 ) 1300 .or_else(|| non_empty(name.as_str())); 1301 let about = optional_arg_or_existing( 1302 args.about.as_ref(), 1303 existing_document.and_then(|document| document.profile.about.as_ref()), 1304 ); 1305 let website = optional_arg_or_existing( 1306 args.website.as_ref(), 1307 existing_document.and_then(|document| document.profile.website.as_ref()), 1308 ); 1309 let picture = optional_arg_or_existing( 1310 args.picture.as_ref(), 1311 existing_document.and_then(|document| document.profile.picture.as_ref()), 1312 ); 1313 let banner = optional_arg_or_existing( 1314 args.banner.as_ref(), 1315 existing_document.and_then(|document| document.profile.banner.as_ref()), 1316 ); 1317 let location_primary = 1318 optional_arg_or_existing(args.location.as_ref(), existing_location.as_ref()) 1319 .unwrap_or_default(); 1320 let city = optional_arg_or_existing(args.city.as_ref(), existing_city.as_ref()); 1321 let region = optional_arg_or_existing(args.region.as_ref(), existing_region.as_ref()); 1322 let country = optional_arg_or_existing(args.country.as_ref(), existing_country.as_ref()); 1323 let delivery_method = 1324 optional_arg_or_existing(args.delivery_method.as_ref(), existing_delivery.as_ref()) 1325 .unwrap_or_default(); 1326 let publication = publication_for_document(existing_document, account, farm_d_tag.as_str()); 1327 1328 Ok(FarmConfigDocument { 1329 version: SUPPORTED_FARM_CONFIG_VERSION, 1330 selection: FarmConfigSelection { 1331 scope, 1332 account: account.record.account_id.to_string(), 1333 farm_d_tag: farm_d_tag.clone(), 1334 }, 1335 profile: RadrootsProfile { 1336 name: name.clone(), 1337 display_name, 1338 nip05: None, 1339 about: about.clone(), 1340 website: website.clone(), 1341 picture: picture.clone(), 1342 banner: banner.clone(), 1343 lud06: None, 1344 lud16: None, 1345 bot: None, 1346 }, 1347 farm: RadrootsFarm { 1348 d_tag: farm_d_tag, 1349 name, 1350 about, 1351 website, 1352 picture, 1353 banner, 1354 location: Some(RadrootsFarmLocation { 1355 primary: non_empty(location_primary.as_str()), 1356 city: city.clone(), 1357 region: region.clone(), 1358 country: country.clone(), 1359 gcs: None, 1360 }), 1361 tags: None, 1362 }, 1363 listing_defaults: FarmListingDefaults { 1364 delivery_method, 1365 location: RadrootsListingLocation { 1366 primary: location_primary, 1367 city, 1368 region, 1369 country, 1370 lat: None, 1371 lng: None, 1372 geohash: None, 1373 }, 1374 }, 1375 publication, 1376 }) 1377 } 1378 1379 fn save_draft_view( 1380 state: &str, 1381 scope: FarmConfigScope, 1382 account: &AccountRecordView, 1383 document: &FarmConfigDocument, 1384 reason: Option<String>, 1385 actions: Vec<String>, 1386 config: &RuntimeConfig, 1387 ) -> Result<FarmSetupView, RuntimeError> { 1388 let written_path = farm_config::write(&config.paths, scope, document)?; 1389 append_farm_local_work( 1390 config, 1391 scope, 1392 written_path.display().to_string(), 1393 document, 1394 Some(account.record.public_identity.public_key_hex.as_str()), 1395 )?; 1396 Ok(FarmSetupView { 1397 state: state.to_owned(), 1398 source: FARM_CONFIG_SOURCE.to_owned(), 1399 config: Some(summary_view( 1400 scope, 1401 written_path.display().to_string(), 1402 document, 1403 Some(account.record.public_identity.public_key_hex.as_str()), 1404 )), 1405 reason, 1406 actions, 1407 }) 1408 } 1409 1410 fn append_farm_local_work( 1411 config: &RuntimeConfig, 1412 scope: FarmConfigScope, 1413 path: String, 1414 document: &FarmConfigDocument, 1415 owner_pubkey: Option<&str>, 1416 ) -> Result<(), RuntimeError> { 1417 let payload = json!({ 1418 "record_kind": "farm_config_v1", 1419 "scope": scope.as_str(), 1420 "path": path, 1421 "document": document, 1422 }); 1423 let subject = format!("farm:{}", document.selection.farm_d_tag); 1424 append_local_work( 1425 config, 1426 subject.as_str(), 1427 Some(document.selection.account.clone()), 1428 owner_pubkey.map(str::to_owned), 1429 Some(document.selection.farm_d_tag.clone()), 1430 None, 1431 payload, 1432 )?; 1433 Ok(()) 1434 } 1435 1436 fn farm_update_actions( 1437 config: &RuntimeConfig, 1438 document: &FarmConfigDocument, 1439 account: Option<&AccountRecordView>, 1440 ) -> Vec<String> { 1441 farm_setup_actions(config, document, account) 1442 } 1443 1444 fn farm_setup_actions( 1445 config: &RuntimeConfig, 1446 document: &FarmConfigDocument, 1447 account: Option<&AccountRecordView>, 1448 ) -> Vec<String> { 1449 let mut actions = vec!["radroots farm readiness check".to_owned()]; 1450 if account.is_none() { 1451 actions.extend(farm_bound_seller_recovery_actions()); 1452 return actions; 1453 } 1454 if farm_config::missing_fields(document).is_empty() 1455 && account 1456 .map(|account| farm_publish_readiness(config, account).executable) 1457 .unwrap_or(false) 1458 { 1459 actions.push("radroots farm publish".to_owned()); 1460 } 1461 actions 1462 } 1463 1464 fn missing_farm_bound_seller_reason(account_id: &str) -> String { 1465 format!("farm-bound seller account `{account_id}` is not present in the local account store") 1466 } 1467 1468 fn farm_bound_seller_recovery_actions() -> Vec<String> { 1469 vec![ 1470 "radroots account import <path>".to_owned(), 1471 "radroots farm rebind <selector>".to_owned(), 1472 ] 1473 } 1474 1475 fn account_recovery_actions() -> Vec<String> { 1476 vec![ 1477 "radroots account import <path>".to_owned(), 1478 "radroots account create".to_owned(), 1479 ] 1480 } 1481 1482 fn missing_blocks_listing_defaults(missing: &[FarmMissingField]) -> bool { 1483 missing.iter().any(|field| { 1484 matches!( 1485 field, 1486 FarmMissingField::Location | FarmMissingField::Delivery 1487 ) 1488 }) 1489 } 1490 1491 fn missing_field_labels(missing: &[FarmMissingField]) -> Vec<String> { 1492 missing 1493 .iter() 1494 .map(|field| field.label().to_owned()) 1495 .collect() 1496 } 1497 1498 fn missing_field_actions(missing: &[FarmMissingField]) -> Vec<String> { 1499 let mut actions = Vec::new(); 1500 for field in missing { 1501 match field { 1502 FarmMissingField::Name => { 1503 push_action(&mut actions, "radroots farm set name \"La Huerta Farm\""); 1504 } 1505 FarmMissingField::Location => { 1506 push_action( 1507 &mut actions, 1508 "radroots farm set location \"San Francisco, CA\"", 1509 ); 1510 } 1511 FarmMissingField::Delivery => { 1512 push_action(&mut actions, "radroots farm set delivery pickup"); 1513 } 1514 FarmMissingField::Country => { 1515 push_action(&mut actions, "radroots farm set country US"); 1516 } 1517 } 1518 } 1519 actions 1520 } 1521 1522 fn push_action(actions: &mut Vec<String>, action: &str) { 1523 if !actions.iter().any(|existing| existing == action) { 1524 actions.push(action.to_owned()); 1525 } 1526 } 1527 1528 fn human_field_name(field: FarmFieldArg) -> &'static str { 1529 match field { 1530 FarmFieldArg::Name => "Name", 1531 FarmFieldArg::DisplayName => "Display name", 1532 FarmFieldArg::About => "About", 1533 FarmFieldArg::Website => "Website", 1534 FarmFieldArg::Picture => "Picture", 1535 FarmFieldArg::Banner => "Banner", 1536 FarmFieldArg::Location => "Location", 1537 FarmFieldArg::City => "City", 1538 FarmFieldArg::Region => "Region", 1539 FarmFieldArg::Country => "Country", 1540 FarmFieldArg::Delivery => "Delivery", 1541 } 1542 } 1543 1544 fn human_field_value(field: FarmFieldArg, value: &str) -> String { 1545 match field { 1546 FarmFieldArg::Delivery => humanize_delivery_method(value), 1547 _ => value.to_owned(), 1548 } 1549 } 1550 1551 fn apply_field_update( 1552 document: &mut FarmConfigDocument, 1553 field: FarmFieldArg, 1554 value: &str, 1555 ) -> Result<(), RuntimeError> { 1556 let value = required_text(value, "farm set value")?; 1557 match field { 1558 FarmFieldArg::Name => { 1559 document.profile.name = value.clone(); 1560 document.farm.name = value; 1561 } 1562 FarmFieldArg::DisplayName => { 1563 document.profile.display_name = Some(value); 1564 } 1565 FarmFieldArg::About => { 1566 document.profile.about = Some(value.clone()); 1567 document.farm.about = Some(value); 1568 } 1569 FarmFieldArg::Website => { 1570 document.profile.website = Some(value.clone()); 1571 document.farm.website = Some(value); 1572 } 1573 FarmFieldArg::Picture => { 1574 document.profile.picture = Some(value.clone()); 1575 document.farm.picture = Some(value); 1576 } 1577 FarmFieldArg::Banner => { 1578 document.profile.banner = Some(value.clone()); 1579 document.farm.banner = Some(value); 1580 } 1581 FarmFieldArg::Location => { 1582 document.listing_defaults.location.primary = value.clone(); 1583 ensure_farm_location(document).primary = Some(value); 1584 } 1585 FarmFieldArg::City => { 1586 document.listing_defaults.location.city = Some(value.clone()); 1587 ensure_farm_location(document).city = Some(value); 1588 } 1589 FarmFieldArg::Region => { 1590 document.listing_defaults.location.region = Some(value.clone()); 1591 ensure_farm_location(document).region = Some(value); 1592 } 1593 FarmFieldArg::Country => { 1594 document.listing_defaults.location.country = Some(value.clone()); 1595 ensure_farm_location(document).country = Some(value); 1596 } 1597 FarmFieldArg::Delivery => { 1598 document.listing_defaults.delivery_method = value; 1599 } 1600 } 1601 Ok(()) 1602 } 1603 1604 fn ensure_farm_location(document: &mut FarmConfigDocument) -> &mut RadrootsFarmLocation { 1605 let primary = non_empty(document.listing_defaults.location.primary.as_str()); 1606 let city = document.listing_defaults.location.city.clone(); 1607 let region = document.listing_defaults.location.region.clone(); 1608 let country = document.listing_defaults.location.country.clone(); 1609 document 1610 .farm 1611 .location 1612 .get_or_insert_with(|| RadrootsFarmLocation { 1613 primary, 1614 city, 1615 region, 1616 country, 1617 gcs: None, 1618 }) 1619 } 1620 1621 fn publication_for_document( 1622 existing_document: Option<&FarmConfigDocument>, 1623 account: &AccountRecordView, 1624 farm_d_tag: &str, 1625 ) -> FarmPublicationStatus { 1626 existing_document 1627 .filter(|document| { 1628 document.farm.d_tag == farm_d_tag 1629 && document.selection.account == account.record.account_id.as_str() 1630 }) 1631 .map(|document| document.publication.clone()) 1632 .unwrap_or_default() 1633 } 1634 1635 fn configured_account( 1636 config: &RuntimeConfig, 1637 account_id: &str, 1638 ) -> Result<Option<AccountRecordView>, RuntimeError> { 1639 let snapshot = account::snapshot(config)?; 1640 Ok(snapshot 1641 .accounts 1642 .into_iter() 1643 .find(|account| account.record.account_id.as_str() == account_id)) 1644 } 1645 1646 fn summary_view( 1647 scope: FarmConfigScope, 1648 path: String, 1649 document: &FarmConfigDocument, 1650 account_pubkey: Option<&str>, 1651 ) -> FarmConfigSummaryView { 1652 FarmConfigSummaryView { 1653 scope: scope.as_str().to_owned(), 1654 path, 1655 seller_account_id: document.selection.account.clone(), 1656 seller_pubkey: account_pubkey.map(str::to_owned), 1657 seller_actor_source: FARM_SELLER_ACTOR_SOURCE.to_owned(), 1658 farm_d_tag: document.selection.farm_d_tag.clone(), 1659 name: resolved_name(document).unwrap_or_default(), 1660 location_primary: resolved_location_primary(document), 1661 delivery_method: resolved_delivery_method(document).unwrap_or_default(), 1662 publication: publication_view(&document.publication), 1663 } 1664 } 1665 1666 fn document_view(document: &FarmConfigDocument) -> FarmConfigDocumentView { 1667 FarmConfigDocumentView { 1668 selection: FarmSelectionView { 1669 scope: document.selection.scope.as_str().to_owned(), 1670 seller_account_id: document.selection.account.clone(), 1671 farm_d_tag: document.selection.farm_d_tag.clone(), 1672 }, 1673 profile: document.profile.clone(), 1674 farm: document.farm.clone(), 1675 listing_defaults: FarmListingDefaultsView { 1676 delivery_method: document.listing_defaults.delivery_method.clone(), 1677 location: document.listing_defaults.location.clone(), 1678 }, 1679 publication: publication_view(&document.publication), 1680 } 1681 } 1682 1683 fn publication_view(publication: &FarmPublicationStatus) -> FarmPublicationView { 1684 FarmPublicationView { 1685 profile_state: publish_state( 1686 publication.profile_event_id.as_deref(), 1687 publication.profile_published_at, 1688 ) 1689 .to_owned(), 1690 farm_state: publish_state( 1691 publication.farm_event_id.as_deref(), 1692 publication.farm_published_at, 1693 ) 1694 .to_owned(), 1695 profile_event_id: publication.profile_event_id.clone(), 1696 farm_event_id: publication.farm_event_id.clone(), 1697 profile_published_at: publication.profile_published_at, 1698 farm_published_at: publication.farm_published_at, 1699 } 1700 } 1701 1702 fn publish_state(event_id: Option<&str>, published_at: Option<u64>) -> &'static str { 1703 if event_id.is_some_and(|value| !value.trim().is_empty()) || published_at.is_some() { 1704 "published" 1705 } else { 1706 "not_published" 1707 } 1708 } 1709 1710 fn scope_from_arg(scope: Option<FarmScopeArg>) -> Option<FarmConfigScope> { 1711 scope.map(|scope| match scope { 1712 FarmScopeArg::User => FarmConfigScope::User, 1713 FarmScopeArg::Workspace => FarmConfigScope::Workspace, 1714 }) 1715 } 1716 1717 fn required_d_tag(value: &str, field: &str) -> Result<String, RuntimeError> { 1718 let value = required_text(value, field)?; 1719 if !is_d_tag_base64url(value.as_str()) { 1720 return Err(RuntimeError::Config(format!( 1721 "{field} must be a 22-character base64url identifier" 1722 ))); 1723 } 1724 Ok(value) 1725 } 1726 1727 fn required_text(value: &str, field: &str) -> Result<String, RuntimeError> { 1728 let trimmed = value.trim(); 1729 if trimmed.is_empty() { 1730 return Err(RuntimeError::Config(format!("{field} must not be empty"))); 1731 } 1732 Ok(trimmed.to_owned()) 1733 } 1734 1735 fn optional_arg_or_existing(arg: Option<&String>, existing: Option<&String>) -> Option<String> { 1736 arg.and_then(|value| non_empty(value.as_str())) 1737 .or_else(|| existing.and_then(|value| non_empty(value.as_str()))) 1738 } 1739 1740 fn draft_name_from_account(account: &AccountRecordView) -> Option<String> { 1741 account 1742 .record 1743 .label 1744 .as_deref() 1745 .and_then(non_empty) 1746 .or_else(|| non_empty(account.record.account_id.as_str())) 1747 } 1748 1749 fn existing_name(existing_document: Option<&FarmConfigDocument>) -> Option<String> { 1750 existing_document.and_then(resolved_name) 1751 } 1752 1753 fn existing_location_primary(existing_document: Option<&FarmConfigDocument>) -> Option<String> { 1754 existing_document.and_then(resolved_location_primary) 1755 } 1756 1757 fn existing_city(existing_document: Option<&FarmConfigDocument>) -> Option<String> { 1758 existing_document 1759 .and_then(|document| { 1760 document 1761 .farm 1762 .location 1763 .as_ref() 1764 .and_then(|location| location.city.as_ref()) 1765 }) 1766 .and_then(|value| non_empty(value.as_str())) 1767 .or_else(|| { 1768 existing_document 1769 .and_then(|document| document.listing_defaults.location.city.as_ref()) 1770 .and_then(|value| non_empty(value.as_str())) 1771 }) 1772 } 1773 1774 fn existing_region(existing_document: Option<&FarmConfigDocument>) -> Option<String> { 1775 existing_document 1776 .and_then(|document| { 1777 document 1778 .farm 1779 .location 1780 .as_ref() 1781 .and_then(|location| location.region.as_ref()) 1782 }) 1783 .and_then(|value| non_empty(value.as_str())) 1784 .or_else(|| { 1785 existing_document 1786 .and_then(|document| document.listing_defaults.location.region.as_ref()) 1787 .and_then(|value| non_empty(value.as_str())) 1788 }) 1789 } 1790 1791 fn existing_country(existing_document: Option<&FarmConfigDocument>) -> Option<String> { 1792 existing_document 1793 .and_then(|document| { 1794 document 1795 .farm 1796 .location 1797 .as_ref() 1798 .and_then(|location| location.country.as_ref()) 1799 }) 1800 .and_then(|value| non_empty(value.as_str())) 1801 .or_else(|| { 1802 existing_document 1803 .and_then(|document| document.listing_defaults.location.country.as_ref()) 1804 .and_then(|value| non_empty(value.as_str())) 1805 }) 1806 } 1807 1808 fn existing_delivery_method(existing_document: Option<&FarmConfigDocument>) -> Option<String> { 1809 existing_document 1810 .and_then(|document| non_empty(document.listing_defaults.delivery_method.as_str())) 1811 } 1812 1813 fn resolved_name(document: &FarmConfigDocument) -> Option<String> { 1814 non_empty(document.profile.name.as_str()).or_else(|| non_empty(document.farm.name.as_str())) 1815 } 1816 1817 fn resolved_location_primary(document: &FarmConfigDocument) -> Option<String> { 1818 non_empty(document.listing_defaults.location.primary.as_str()).or_else(|| { 1819 document 1820 .farm 1821 .location 1822 .as_ref() 1823 .and_then(|location| location.primary.as_deref()) 1824 .and_then(non_empty) 1825 }) 1826 } 1827 1828 fn resolved_delivery_method(document: &FarmConfigDocument) -> Option<String> { 1829 non_empty(document.listing_defaults.delivery_method.as_str()) 1830 } 1831 1832 fn humanize_delivery_method(value: &str) -> String { 1833 value 1834 .split('_') 1835 .filter(|segment| !segment.is_empty()) 1836 .map(capitalize_ascii_word) 1837 .collect::<Vec<_>>() 1838 .join(" ") 1839 } 1840 1841 fn capitalize_ascii_word(word: &str) -> String { 1842 let mut chars = word.chars(); 1843 let Some(first) = chars.next() else { 1844 return String::new(); 1845 }; 1846 let mut rendered = String::new(); 1847 rendered.push(first.to_ascii_uppercase()); 1848 rendered.push_str(chars.as_str()); 1849 rendered 1850 } 1851 1852 fn non_empty(value: &str) -> Option<String> { 1853 let trimmed = value.trim(); 1854 if trimmed.is_empty() { 1855 None 1856 } else { 1857 Some(trimmed.to_owned()) 1858 } 1859 } 1860 1861 fn now_unix() -> u64 { 1862 SystemTime::now() 1863 .duration_since(UNIX_EPOCH) 1864 .map(|duration| duration.as_secs()) 1865 .unwrap_or_default() 1866 } 1867 1868 fn generate_d_tag() -> String { 1869 let nanos = SystemTime::now() 1870 .duration_since(UNIX_EPOCH) 1871 .map(|duration| duration.as_nanos()) 1872 .unwrap_or_default(); 1873 let counter = D_TAG_COUNTER.fetch_add(1, Ordering::Relaxed) as u128; 1874 encode_base64url_no_pad((nanos ^ counter).to_be_bytes()) 1875 } 1876 1877 fn encode_base64url_no_pad(bytes: [u8; 16]) -> String { 1878 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; 1879 let mut output = String::with_capacity(22); 1880 let mut index = 0usize; 1881 while index + 3 <= bytes.len() { 1882 let block = ((bytes[index] as u32) << 16) 1883 | ((bytes[index + 1] as u32) << 8) 1884 | (bytes[index + 2] as u32); 1885 output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); 1886 output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); 1887 output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char); 1888 output.push(ALPHABET[(block & 0x3f) as usize] as char); 1889 index += 3; 1890 } 1891 let remaining = bytes.len() - index; 1892 if remaining == 1 { 1893 let block = (bytes[index] as u32) << 16; 1894 output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); 1895 output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); 1896 } else if remaining == 2 { 1897 let block = ((bytes[index] as u32) << 16) | ((bytes[index + 1] as u32) << 8); 1898 output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); 1899 output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); 1900 output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char); 1901 } 1902 output 1903 } 1904 1905 #[cfg(test)] 1906 mod tests { 1907 use super::generate_d_tag; 1908 use radroots_events_codec::d_tag::is_d_tag_base64url; 1909 1910 #[test] 1911 fn generated_farm_d_tag_is_valid_base64url() { 1912 assert!(is_d_tag_base64url(&generate_d_tag())); 1913 } 1914 }