cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 328ab327704429e3b488aa73f9b6b37f643d90a0
parent 28a2ce87aecd74e4bec0502a7231afeff55ea3d6
Author: triesap <tyson@radroots.org>
Date:   Sat,  9 May 2026 15:07:19 +0000

cli: add explicit farm seller rebind

- add farm.rebind parsing, operation wiring, approval gating, and dry-run/live runtime behavior
- expose farm seller bindings with seller_actor_source fields and remove selected-account farm output
- prevent farm create from retargeting an existing farm through ambient account drift
- bind farm publish and listing defaults to farm config with drift and watch-only process coverage

Diffstat:
Msrc/domain/runtime.rs | 50+++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/main.rs | 3+++
Msrc/operation_adapter.rs | 26++++++++++++++++++++++++++
Msrc/operation_farm.rs | 67++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/operation_registry.rs | 20+++++++++++++++++++-
Msrc/runtime/accounts.rs | 23+++++++++++++++++++++++
Msrc/runtime/farm.rs | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/runtime_args.rs | 6++++++
Msrc/target_cli.rs | 28+++++++++++++++++++++++++---
Mtests/signer_runtime_modes.rs | 362+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/target_cli.rs | 5+++++
11 files changed, 730 insertions(+), 37 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -743,6 +743,44 @@ impl FarmSetView { } #[derive(Debug, Clone, Serialize)] +pub struct FarmRebindView { + pub state: String, + pub source: String, + pub scope: String, + pub path: String, + pub config_present: bool, + pub dry_run: bool, + pub seller_actor_source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub from_seller_account_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub from_seller_pubkey: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub to_seller_account_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub to_seller_pubkey: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub seller_pubkey_changed: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] + pub publication_state_action: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub config: Option<FarmConfigSummaryView>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl FarmRebindView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "unconfigured" => CommandDisposition::Unconfigured, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] pub struct FarmStatusView { pub state: String, pub source: String, @@ -808,8 +846,9 @@ pub struct FarmPublishView { pub path: String, pub config_present: bool, pub dry_run: bool, - pub selected_account_id: String, - pub selected_account_pubkey: String, + pub seller_account_id: String, + pub seller_pubkey: String, + pub seller_actor_source: String, pub farm_d_tag: String, #[serde(skip_serializing_if = "Option::is_none")] pub requested_signer_session_id: Option<String>, @@ -908,9 +947,10 @@ pub struct RelayFailureView { pub struct FarmConfigSummaryView { pub scope: String, pub path: String, - pub selected_account_id: String, + pub seller_account_id: String, #[serde(skip_serializing_if = "Option::is_none")] - pub selected_account_pubkey: Option<String>, + pub seller_pubkey: Option<String>, + pub seller_actor_source: String, pub farm_d_tag: String, pub name: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -931,7 +971,7 @@ pub struct FarmConfigDocumentView { #[derive(Debug, Clone, Serialize)] pub struct FarmSelectionView { pub scope: String, - pub account: String, + pub seller_account_id: String, pub farm_d_tag: String, } diff --git a/src/main.rs b/src/main.rs @@ -198,6 +198,9 @@ fn execute_request( TargetOperationRequest::FarmGet(request) => { execute_with(FarmOperationService::new(config), request) } + TargetOperationRequest::FarmRebind(request) => { + execute_with(FarmOperationService::new(config), request) + } TargetOperationRequest::FarmProfileUpdate(request) => { execute_with(FarmOperationService::new(config), request) } diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -1294,6 +1294,9 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati insert_string(&mut input, "country", &args.country); insert_string(&mut input, "delivery_method", &args.delivery_method); } + FarmCommand::Rebind(args) => { + insert_string(&mut input, "selector", &args.selector); + } FarmCommand::Profile(args) => match &args.command { FarmProfileCommand::Update(args) => { insert_string(&mut input, "field", &args.field); @@ -1563,6 +1566,7 @@ target_operation_contracts! { SyncWatch => (SyncWatchRequest, SyncWatchResult, "sync.watch"), FarmCreate => (FarmCreateRequest, FarmCreateResult, "farm.create"), FarmGet => (FarmGetRequest, FarmGetResult, "farm.get"), + FarmRebind => (FarmRebindRequest, FarmRebindResult, "farm.rebind"), FarmProfileUpdate => (FarmProfileUpdateRequest, FarmProfileUpdateResult, "farm.profile.update"), FarmLocationUpdate => (FarmLocationUpdateRequest, FarmLocationUpdateResult, "farm.location.update"), FarmFulfillmentUpdate => (FarmFulfillmentUpdateRequest, FarmFulfillmentUpdateResult, "farm.fulfillment.update"), @@ -1750,6 +1754,28 @@ mod tests { } #[test] + fn adapter_maps_farm_rebind_selector() { + let parsed = TargetCliArgs::try_parse_from(["radroots", "farm", "rebind", "acct_test"]) + .expect("target args parse"); + + let request = TargetOperationRequest::from_target_args(&parsed) + .expect("operation request from target args"); + let TargetOperationRequest::FarmRebind(request) = request else { + panic!("expected farm rebind request") + }; + + assert_eq!(request.operation_id(), "farm.rebind"); + assert_eq!( + request + .payload + .input + .get("selector") + .and_then(Value::as_str), + Some("acct_test") + ); + } + + #[test] fn adapter_maps_order_fulfillment_update_input() { let parsed = TargetCliArgs::try_parse_from([ "radroots", diff --git a/src/operation_farm.rs b/src/operation_farm.rs @@ -6,14 +6,15 @@ use crate::operation_adapter::{ FarmCreateRequest, FarmCreateResult, FarmFulfillmentUpdateRequest, FarmFulfillmentUpdateResult, FarmGetRequest, FarmGetResult, FarmLocationUpdateRequest, FarmLocationUpdateResult, FarmProfileUpdateRequest, FarmProfileUpdateResult, FarmPublishRequest, FarmPublishResult, - FarmReadinessCheckRequest, FarmReadinessCheckResult, OperationAdapterError, OperationRequest, - OperationRequestData, OperationRequestPayload, OperationResult, OperationResultData, - OperationService, + FarmReadinessCheckRequest, FarmReadinessCheckResult, FarmRebindRequest, FarmRebindResult, + OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, + OperationResult, OperationResultData, OperationService, }; use crate::runtime::RuntimeError; use crate::runtime::config::{PublishMode, RuntimeConfig}; use crate::runtime_args::{ - FarmCreateArgs, FarmFieldArg, FarmPublishArgs, FarmScopeArg, FarmScopedArgs, FarmUpdateArgs, + FarmCreateArgs, FarmFieldArg, FarmPublishArgs, FarmRebindArgs, FarmScopeArg, FarmScopedArgs, + FarmUpdateArgs, }; pub struct FarmOperationService<'a> { @@ -49,11 +50,16 @@ impl OperationService<FarmCreateRequest> for FarmOperationService<'_> { delivery_method: string_input(&request, "delivery_method"), }; if request.context.dry_run { - let view = map_runtime(crate::runtime::farm::init_preflight(self.config, &args))?; + let view = + crate::runtime::farm::init_preflight(self.config, &args).map_err(|error| { + OperationAdapterError::runtime_failure(request.operation_id(), error) + })?; return serialized_operation_result::<FarmCreateResult, _>(&view); } - let view = map_runtime(crate::runtime::farm::init(self.config, &args))?; + let view = crate::runtime::farm::init(self.config, &args).map_err(|error| { + OperationAdapterError::runtime_failure(request.operation_id(), error) + })?; serialized_operation_result::<FarmCreateResult, _>(&view) } } @@ -73,6 +79,37 @@ impl OperationService<FarmGetRequest> for FarmOperationService<'_> { } } +impl OperationService<FarmRebindRequest> for FarmOperationService<'_> { + type Result = FarmRebindResult; + + fn execute( + &self, + request: OperationRequest<FarmRebindRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let args = FarmRebindArgs { + scope: scope_input(&request)?, + selector: required_string(&request, "selector")?, + }; + if request.context.dry_run { + let view = + crate::runtime::farm::rebind_preflight(self.config, &args).map_err(|error| { + OperationAdapterError::runtime_failure(request.operation_id(), error) + })?; + return serialized_operation_result::<FarmRebindResult, _>(&view); + } + if request.context.requires_approval_token() { + return Err(OperationAdapterError::approval_required( + request.operation_id(), + )); + } + + let view = crate::runtime::farm::rebind(self.config, &args).map_err(|error| { + OperationAdapterError::runtime_failure(request.operation_id(), error) + })?; + serialized_operation_result::<FarmRebindResult, _>(&view) + } +} + impl OperationService<FarmProfileUpdateRequest> for FarmOperationService<'_> { type Result = FarmProfileUpdateResult; @@ -348,7 +385,7 @@ mod tests { use super::FarmOperationService; use crate::operation_adapter::{ FarmCreateRequest, FarmGetRequest, FarmPublishRequest, FarmReadinessCheckRequest, - OperationAdapter, OperationContext, OperationData, OperationRequest, + FarmRebindRequest, OperationAdapter, OperationContext, OperationData, OperationRequest, }; use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, @@ -425,6 +462,22 @@ mod tests { assert_eq!(error.to_output_error().exit_code, 6); } + #[test] + fn farm_rebind_requires_approval_token_unless_dry_run() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(FarmOperationService::new(&config)); + let request = OperationRequest::new( + OperationContext::default(), + FarmRebindRequest::from_data(data(&[("selector", "acct_test")])), + ) + .expect("farm rebind request"); + let error = service.execute(request).expect_err("approval required"); + assert!(format!("{error}").contains("approval_token")); + assert_eq!(error.to_output_error().code, "approval_required"); + assert_eq!(error.to_output_error().exit_code, 6); + } + fn sample_config(root: &Path) -> RuntimeConfig { let data = root.join("data"); let logs = root.join("logs"); diff --git a/src/operation_registry.rs b/src/operation_registry.rs @@ -473,6 +473,21 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ false ), operation!( + "farm.rebind", + "radroots farm rebind", + "farm", + "farm_rebind", + "FarmRebindRequest", + "FarmRebindResult", + "Rebind farm seller account.", + Seller, + true, + Required, + High, + false, + true + ), + operation!( "farm.profile.update", "radroots farm profile update", "farm", @@ -1213,6 +1228,7 @@ mod tests { "sync.watch", "farm.create", "farm.get", + "farm.rebind", "farm.profile.update", "farm.location.update", "farm.fulfillment.update", @@ -1270,6 +1286,7 @@ mod tests { "sync.pull", "sync.push", "farm.create", + "farm.rebind", "farm.profile.update", "farm.location.update", "farm.fulfillment.update", @@ -1307,7 +1324,7 @@ mod tests { .copied() .collect::<BTreeSet<_>>(); assert_eq!(actual, expected); - assert_eq!(OPERATION_REGISTRY.len(), 68); + assert_eq!(OPERATION_REGISTRY.len(), 69); } #[test] @@ -1352,6 +1369,7 @@ mod tests { "account.attach_secret", "account.remove", "sync.push", + "farm.rebind", "farm.publish", "listing.publish", "listing.archive", diff --git a/src/runtime/accounts.rs b/src/runtime/accounts.rs @@ -436,6 +436,29 @@ pub fn resolve_local_signing_identity( Ok(AccountSigningIdentity { account, identity }) } +pub fn resolve_local_signing_identity_for_account( + config: &RuntimeConfig, + account_id: &str, +) -> Result<AccountSigningIdentity, RuntimeError> { + let manager = account_manager(config)?; + let snapshot = snapshot_from_manager(&manager)?; + let Some(account) = snapshot + .accounts + .iter() + .find(|account| account.record.account_id.as_str() == account_id) + .cloned() + else { + return Err(AccountRuntimeFailure::unresolved(format!( + "farm-bound seller account `{account_id}` is not present in the local account store" + )) + .into()); + }; + let Some(identity) = manager.get_signing_identity(&account.record.account_id)? else { + return Err(AccountRuntimeFailure::watch_only(&account.record.account_id).into()); + }; + Ok(AccountSigningIdentity { account, identity }) +} + pub fn account_summary_view(account: &AccountRecordView) -> AccountSummaryView { AccountSummaryView::from_account_runtime( &account.record, diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -19,7 +19,7 @@ use radroots_sdk::{ use crate::domain::runtime::{ FarmConfigDocumentView, FarmConfigSummaryView, FarmGetView, FarmListingDefaultsView, FarmPublicationView, FarmPublishComponentView, FarmPublishEventView, FarmPublishJobView, - FarmPublishView, FarmSelectionView, FarmSetView, FarmSetupView, FarmStatusView, + FarmPublishView, FarmRebindView, FarmSelectionView, FarmSetView, FarmSetupView, FarmStatusView, RelayFailureView, }; use crate::runtime::RuntimeError; @@ -37,10 +37,12 @@ use crate::runtime::farm_config::{ }; use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority}; use crate::runtime_args::{ - FarmCreateArgs, FarmFieldArg, FarmPublishArgs, FarmScopeArg, FarmScopedArgs, FarmUpdateArgs, + FarmCreateArgs, FarmFieldArg, FarmPublishArgs, FarmRebindArgs, FarmScopeArg, FarmScopedArgs, + FarmUpdateArgs, }; const FARM_CONFIG_SOURCE: &str = "farm config · local first"; +const FARM_SELLER_ACTOR_SOURCE: &str = "farm_config"; const RELAY_FARM_WRITE_SOURCE: &str = "direct Nostr relay publish · local key"; const RADROOTSD_FARM_WRITE_SOURCE: &str = "radrootsd publish transport · signer session"; const RADROOTSD_BRIDGE_PROFILE_PUBLISH_METHOD: &str = "bridge.profile.publish"; @@ -99,6 +101,111 @@ pub fn init_preflight( }) } +pub fn rebind( + config: &RuntimeConfig, + args: &FarmRebindArgs, +) -> Result<FarmRebindView, RuntimeError> { + rebind_inner(config, args, false) +} + +pub fn rebind_preflight( + config: &RuntimeConfig, + args: &FarmRebindArgs, +) -> Result<FarmRebindView, RuntimeError> { + rebind_inner(config, args, true) +} + +fn rebind_inner( + config: &RuntimeConfig, + args: &FarmRebindArgs, + dry_run: bool, +) -> Result<FarmRebindView, RuntimeError> { + let scope = scope_from_arg(args.scope); + let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; + let path = farm_config::config_path(&config.paths, resolved_scope)?; + let Some(resolved) = farm_config::load(config, Some(resolved_scope))? else { + return Ok(FarmRebindView { + state: "unconfigured".to_owned(), + source: FARM_CONFIG_SOURCE.to_owned(), + scope: resolved_scope.as_str().to_owned(), + path: path.display().to_string(), + config_present: false, + dry_run, + seller_actor_source: FARM_SELLER_ACTOR_SOURCE.to_owned(), + from_seller_account_id: None, + from_seller_pubkey: None, + to_seller_account_id: None, + to_seller_pubkey: None, + seller_pubkey_changed: None, + publication_state_action: None, + config: None, + reason: Some(format!("no farm config found at {}", path.display())), + actions: vec!["radroots farm create".to_owned()], + }); + }; + + let from_account = configured_account(config, &resolved.document.selection.account)?; + let from_seller_pubkey = from_account + .as_ref() + .map(|account| account.record.public_identity.public_key_hex.clone()); + let target_account = accounts::resolve_account_selector(config, args.selector.as_str())?; + let to_seller_pubkey = target_account.record.public_identity.public_key_hex.clone(); + let seller_pubkey_changed = from_seller_pubkey + .as_deref() + .is_none_or(|pubkey| !pubkey.eq_ignore_ascii_case(to_seller_pubkey.as_str())); + let publication_state_action = if seller_pubkey_changed { + "cleared" + } else { + "preserved" + }; + let mut document = resolved.document.clone(); + document.selection.account = target_account.record.account_id.to_string(); + if seller_pubkey_changed { + document.publication = FarmPublicationStatus::default(); + } + let written_path = if dry_run { + resolved.path.clone() + } else { + farm_config::write(&config.paths, resolved.scope, &document)? + }; + let state = if dry_run { "dry_run" } else { "rebound" }; + + Ok(FarmRebindView { + state: state.to_owned(), + source: FARM_CONFIG_SOURCE.to_owned(), + scope: resolved.scope.as_str().to_owned(), + path: written_path.display().to_string(), + config_present: true, + dry_run, + seller_actor_source: FARM_SELLER_ACTOR_SOURCE.to_owned(), + from_seller_account_id: Some(resolved.document.selection.account.clone()), + from_seller_pubkey, + to_seller_account_id: Some(target_account.record.account_id.to_string()), + to_seller_pubkey: Some(to_seller_pubkey.clone()), + seller_pubkey_changed: Some(seller_pubkey_changed), + publication_state_action: Some(publication_state_action.to_owned()), + config: Some(summary_view( + resolved.scope, + written_path.display().to_string(), + &document, + Some(to_seller_pubkey.as_str()), + )), + reason: Some(if dry_run { + "dry run requested; farm seller binding was not written".to_owned() + } else { + "farm seller binding updated".to_owned() + }), + actions: if dry_run { + vec![format!( + "radroots --approval-token approve farm rebind {}", + args.selector + )] + } else { + vec!["radroots farm readiness check".to_owned()] + }, + }) +} + pub fn set(config: &RuntimeConfig, args: &FarmUpdateArgs) -> Result<FarmSetView, RuntimeError> { let scope = scope_from_arg(args.scope); let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; @@ -245,7 +352,8 @@ pub fn status( }; let mut actions = Vec::new(); if account.is_none() { - actions.push("radroots account create".to_owned()); + actions.push("radroots account import <path>".to_owned()); + actions.push("radroots farm rebind <selector>".to_owned()); } else if draft_missing.is_empty() { actions.extend(publish.actions.clone()); } else { @@ -275,7 +383,7 @@ pub fn status( account_pubkey, )), missing: if account.is_none() { - vec!["Selected account".to_owned()] + vec!["Farm-bound seller account".to_owned()] } else { let mut missing = missing_field_labels(draft_missing.as_slice()); missing.extend(publish.missing); @@ -382,8 +490,11 @@ fn relay_farm_publish_readiness( reason: Some( accounts::AccountRuntimeFailure::watch_only(&account.record.account_id).to_string(), ), - missing: vec!["Write-capable account".to_owned()], - actions: vec!["radroots account attach-secret".to_owned()], + missing: vec!["Write-capable farm-bound seller account".to_owned()], + actions: vec![format!( + "radroots account attach-secret {} <path>", + account.record.account_id + )], }; } @@ -469,8 +580,11 @@ pub fn publish( "farm config account `{}` is not present in the local account store", resolved.document.selection.account ), - vec!["Selected account".to_owned()], - vec!["radroots account create".to_owned()], + vec!["Farm-bound seller account".to_owned()], + vec![ + "radroots account import <path>".to_owned(), + "radroots farm rebind <selector>".to_owned(), + ], config.output.dry_run, true, resolved.document.selection.account.clone(), @@ -545,7 +659,11 @@ fn dry_run_publish_view( ) -> Result<FarmPublishView, RuntimeError> { match config.publish.mode { PublishMode::NostrRelay => { - if let Err(error) = resolve_farm_signing_identity(config, account_pubkey) { + if let Err(error) = resolve_farm_signing_identity( + config, + resolved.document.selection.account.as_str(), + account_pubkey, + ) { return match error { ActorWriteBindingError::Account(failure) => Err(failure.into()), error => Ok(binding_error_publish_view( @@ -650,7 +768,11 @@ fn publish_via_direct_relay( profile_idempotency_key: Option<String>, farm_idempotency_key: Option<String>, ) -> Result<FarmPublishView, RuntimeError> { - let signing = match resolve_farm_signing_identity(config, account_pubkey.as_str()) { + let signing = match resolve_farm_signing_identity( + config, + resolved.document.selection.account.as_str(), + account_pubkey.as_str(), + ) { Ok(signing) => signing, Err(ActorWriteBindingError::Account(failure)) => return Err(failure.into()), Err(error) => { @@ -883,8 +1005,8 @@ fn missing_publish_view( actions: Vec<String>, dry_run: bool, config_present: bool, - selected_account_id: String, - selected_account_pubkey: String, + seller_account_id: String, + seller_pubkey: String, farm_d_tag: String, ) -> FarmPublishView { FarmPublishView { @@ -894,8 +1016,9 @@ fn missing_publish_view( path, config_present, dry_run, - selected_account_id, - selected_account_pubkey, + seller_account_id, + seller_pubkey, + seller_actor_source: FARM_SELLER_ACTOR_SOURCE.to_owned(), farm_d_tag, requested_signer_session_id: args.signer_session_id.clone(), profile: not_submitted_component( @@ -914,6 +1037,7 @@ fn missing_publish_view( fn resolve_farm_signing_identity( config: &RuntimeConfig, + account_id: &str, account_pubkey: &str, ) -> Result<accounts::AccountSigningIdentity, ActorWriteBindingError> { if !matches!( @@ -926,7 +1050,7 @@ fn resolve_farm_signing_identity( )) }); } - let signing = accounts::resolve_local_signing_identity(config) + let signing = accounts::resolve_local_signing_identity_for_account(config, account_id) .map_err(ActorWriteBindingError::from_runtime)?; let selected_pubkey = signing .account @@ -937,7 +1061,7 @@ fn resolve_farm_signing_identity( if !selected_pubkey.eq_ignore_ascii_case(account_pubkey) { return Err(ActorWriteBindingError::Account( accounts::AccountRuntimeFailure::mismatch(format!( - "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign farm pubkey `{account_pubkey}`" + "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign farm-bound seller pubkey `{account_pubkey}`" )), )); } @@ -962,8 +1086,9 @@ fn base_publish_view( path: resolved.path.display().to_string(), config_present: true, dry_run: config.output.dry_run, - selected_account_id: resolved.document.selection.account.clone(), - selected_account_pubkey: account_pubkey.to_owned(), + seller_account_id: resolved.document.selection.account.clone(), + seller_pubkey: account_pubkey.to_owned(), + seller_actor_source: FARM_SELLER_ACTOR_SOURCE.to_owned(), farm_d_tag: resolved.document.selection.farm_d_tag.clone(), requested_signer_session_id: args.signer_session_id.clone(), profile, @@ -1606,6 +1731,15 @@ fn init_document( args: &FarmCreateArgs, ) -> Result<FarmConfigDocument, RuntimeError> { let existing_document = existing.map(|resolved| &resolved.document); + if let Some(document) = existing_document + && document.selection.account != account.record.account_id.to_string() + { + return Err(accounts::AccountRuntimeFailure::mismatch(format!( + "account mismatch: farm config is bound to seller account `{}`; use `radroots farm rebind {}` to change the farm-bound seller account", + document.selection.account, account.record.account_id + )) + .into()); + } let farm_d_tag = match args.farm_d_tag.as_deref() { Some(value) => required_d_tag(value, "farm_d_tag")?, None => existing_document @@ -1925,8 +2059,9 @@ fn summary_view( FarmConfigSummaryView { scope: scope.as_str().to_owned(), path, - selected_account_id: document.selection.account.clone(), - selected_account_pubkey: account_pubkey.map(str::to_owned), + seller_account_id: document.selection.account.clone(), + seller_pubkey: account_pubkey.map(str::to_owned), + seller_actor_source: FARM_SELLER_ACTOR_SOURCE.to_owned(), farm_d_tag: document.selection.farm_d_tag.clone(), name: resolved_name(document).unwrap_or_default(), location_primary: resolved_location_primary(document), @@ -1939,7 +2074,7 @@ fn document_view(document: &FarmConfigDocument) -> FarmConfigDocumentView { FarmConfigDocumentView { selection: FarmSelectionView { scope: document.selection.scope.as_str().to_owned(), - account: document.selection.account.clone(), + seller_account_id: document.selection.account.clone(), farm_d_tag: document.selection.farm_d_tag.clone(), }, profile: document.profile.clone(), diff --git a/src/runtime_args.rs b/src/runtime_args.rs @@ -118,6 +118,12 @@ pub struct FarmCreateArgs { } #[derive(Debug, Clone)] +pub struct FarmRebindArgs { + pub scope: Option<FarmScopeArg>, + pub selector: String, +} + +#[derive(Debug, Clone)] pub struct FarmUpdateArgs { pub scope: Option<FarmScopeArg>, pub field: FarmFieldArg, diff --git a/src/target_cli.rs b/src/target_cli.rs @@ -164,6 +164,7 @@ impl TargetCommand { Self::Farm(args) => match &args.command { FarmCommand::Create(_) => "farm.create", FarmCommand::Get => "farm.get", + FarmCommand::Rebind(_) => "farm.rebind", FarmCommand::Profile(profile) => match profile.command { FarmProfileCommand::Update(_) => "farm.profile.update", }, @@ -467,6 +468,7 @@ pub struct FarmArgs { pub enum FarmCommand { Create(FarmCreateArgs), Get, + Rebind(FarmRebindArgs), Profile(FarmProfileArgs), Location(FarmLocationArgs), Fulfillment(FarmFulfillmentArgs), @@ -503,6 +505,11 @@ pub struct FarmCreateArgs { } #[derive(Debug, Clone, Args)] +pub struct FarmRebindArgs { + pub selector: Option<String>, +} + +#[derive(Debug, Clone, Args)] pub struct FarmProfileArgs { #[command(subcommand)] pub command: FarmProfileCommand, @@ -1040,9 +1047,9 @@ mod tests { use clap::{CommandFactory, Parser}; use super::{ - AccountCommand, OrderCommand, OrderFulfillmentCommand, OrderFulfillmentStateArg, - OrderPaymentCommand, OrderReceiptCommand, OrderRevisionCommand, OrderSettlementCommand, - TargetCliArgs, TargetOutputFormat, + AccountCommand, FarmCommand, OrderCommand, OrderFulfillmentCommand, + OrderFulfillmentStateArg, OrderPaymentCommand, OrderReceiptCommand, OrderRevisionCommand, + OrderSettlementCommand, TargetCliArgs, TargetOutputFormat, }; use crate::operation_registry::OPERATION_REGISTRY; @@ -1158,6 +1165,21 @@ mod tests { } #[test] + fn target_parser_accepts_farm_rebind_selector() { + let parsed = TargetCliArgs::try_parse_from(["radroots", "farm", "rebind", "acct_test"]) + .expect("target args parse"); + + assert_eq!(parsed.command.operation_id(), "farm.rebind"); + let crate::target_cli::TargetCommand::Farm(farm) = parsed.command else { + panic!("expected farm command") + }; + let FarmCommand::Rebind(args) = farm.command else { + panic!("expected farm rebind command") + }; + assert_eq!(args.selector.as_deref(), Some("acct_test")); + } + + #[test] fn target_parser_accepts_order_fulfillment_update_state() { let parsed = TargetCliArgs::try_parse_from([ "radroots", diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -1393,6 +1393,368 @@ fn local_farm_publish_persists_publication_after_profile_and_farm_publish() { } #[test] +fn farm_rebind_is_explicit_and_publish_defaults_ignore_ambient_selection() { + let sandbox = RadrootsCliSandbox::new(); + let first = sandbox.json_success(&["--format", "json", "account", "create"]); + let first_account_id = first["result"]["account"]["id"] + .as_str() + .expect("first account id"); + let farm = sandbox.json_success(&[ + "--format", + "json", + "farm", + "create", + "--name", + "Green Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + let farm_path = farm["result"]["config"]["path"] + .as_str() + .expect("farm path"); + let farm_d_tag = farm["result"]["config"]["farm_d_tag"] + .as_str() + .expect("farm d tag"); + let first_pubkey = farm["result"]["config"]["seller_pubkey"] + .as_str() + .expect("first pubkey"); + assert_eq!( + farm["result"]["config"]["seller_account_id"], + first_account_id + ); + assert_eq!(farm["result"]["config"]["seller_pubkey"], first_pubkey); + assert_eq!( + farm["result"]["config"]["seller_actor_source"], + "farm_config" + ); + assert!( + farm["result"]["config"] + .get("selected_account_id") + .is_none() + ); + + let relay = FarmPartialRelayServer::profile_and_farm_accept(); + let relay_url = relay.endpoint().to_owned(); + sandbox.json_success(&[ + "--format", + "json", + "--relay", + relay_url.as_str(), + "--approval-token", + "approve", + "farm", + "publish", + ]); + let _requests = relay.take_requests(); + let published = sandbox.json_success(&["--format", "json", "farm", "get"]); + assert_eq!( + published["result"]["document"]["publication"]["profile_state"], + "published" + ); + assert_eq!( + published["result"]["document"]["publication"]["farm_state"], + "published" + ); + + let same_seller_dry_run = sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "farm", + "rebind", + first_account_id, + ]); + assert_eq!(same_seller_dry_run["operation_id"], "farm.rebind"); + assert_eq!( + same_seller_dry_run["result"]["publication_state_action"], + "preserved" + ); + + let second = sandbox.json_success(&["--format", "json", "account", "create"]); + let second_account_id = second["result"]["account"]["id"] + .as_str() + .expect("second account id"); + assert_ne!(first_account_id, second_account_id); + sandbox.json_success(&[ + "--format", + "json", + "account", + "selection", + "update", + second_account_id, + ]); + + let (retarget_output, retarget) = sandbox.json_output(&[ + "--format", + "json", + "farm", + "create", + "--name", + "Green Farm Retarget", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + assert!(!retarget_output.status.success()); + assert_eq!(retarget["operation_id"], "farm.create"); + assert_eq!(retarget["errors"][0]["code"], "account_mismatch"); + assert_contains(&retarget["errors"][0]["message"], "farm-bound seller"); + + let publish_dry_run = sandbox.json_success(&[ + "--format", + "json", + "--relay", + "ws://127.0.0.1:9", + "--dry-run", + "farm", + "publish", + ]); + assert_eq!(publish_dry_run["operation_id"], "farm.publish"); + assert_eq!(publish_dry_run["result"]["state"], "dry_run"); + assert_eq!( + publish_dry_run["result"]["seller_account_id"], + first_account_id + ); + assert_eq!(publish_dry_run["result"]["seller_pubkey"], first_pubkey); + assert!( + publish_dry_run["result"] + .get("selected_account_id") + .is_none() + ); + + let listing_path = sandbox.root().join("drift-listing.toml"); + let listing = sandbox.json_success(&[ + "--format", + "json", + "listing", + "create", + "--output", + listing_path.to_string_lossy().as_ref(), + "--key", + "drift-eggs", + "--title", + "Eggs", + "--category", + "eggs", + "--summary", + "Fresh eggs", + "--bin-id", + "bin-1", + "--quantity-amount", + "1", + "--quantity-unit", + "each", + "--price-amount", + "6", + "--price-currency", + "USD", + "--price-per-amount", + "1", + "--price-per-unit", + "each", + "--available", + "10", + ]); + assert_eq!(listing["operation_id"], "listing.create"); + assert_eq!(listing["result"]["seller_pubkey"], first_pubkey); + assert_eq!(listing["result"]["farm_d_tag"], farm_d_tag); + + let farm_before_dry_run = fs::read_to_string(farm_path).expect("farm before dry-run rebind"); + let dry_rebind = sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "farm", + "rebind", + second_account_id, + ]); + assert_eq!(dry_rebind["operation_id"], "farm.rebind"); + assert_eq!(dry_rebind["result"]["state"], "dry_run"); + assert_eq!( + dry_rebind["result"]["from_seller_account_id"], + first_account_id + ); + assert_eq!(dry_rebind["result"]["from_seller_pubkey"], first_pubkey); + assert_eq!( + dry_rebind["result"]["to_seller_account_id"], + second_account_id + ); + let second_pubkey = dry_rebind["result"]["to_seller_pubkey"] + .as_str() + .expect("second pubkey"); + assert_eq!(dry_rebind["result"]["to_seller_pubkey"], second_pubkey); + assert_eq!(dry_rebind["result"]["seller_pubkey_changed"], true); + assert_eq!(dry_rebind["result"]["publication_state_action"], "cleared"); + assert_eq!( + fs::read_to_string(farm_path).expect("farm after dry-run rebind"), + farm_before_dry_run + ); + + let (unapproved_output, unapproved) = + sandbox.json_output(&["--format", "json", "farm", "rebind", second_account_id]); + assert!(!unapproved_output.status.success()); + assert_eq!(unapproved["operation_id"], "farm.rebind"); + assert_eq!(unapproved["errors"][0]["code"], "approval_required"); + + let rebound = sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "farm", + "rebind", + second_account_id, + ]); + assert_eq!(rebound["operation_id"], "farm.rebind"); + assert_eq!(rebound["result"]["state"], "rebound"); + assert_eq!( + rebound["result"]["config"]["seller_account_id"], + second_account_id + ); + assert_eq!(rebound["result"]["config"]["seller_pubkey"], second_pubkey); + assert_eq!(rebound["result"]["config"]["farm_d_tag"], farm_d_tag); + assert_eq!(rebound["result"]["config"]["name"], "Green Farm"); + assert_eq!(rebound["result"]["config"]["location_primary"], "farmstand"); + assert_eq!(rebound["result"]["config"]["delivery_method"], "pickup"); + assert_eq!(rebound["result"]["publication_state_action"], "cleared"); + + let rebound_get = sandbox.json_success(&["--format", "json", "farm", "get"]); + assert_eq!( + rebound_get["result"]["document"]["selection"]["seller_account_id"], + second_account_id + ); + assert_eq!( + rebound_get["result"]["document"]["publication"]["profile_state"], + "not_published" + ); + assert_eq!( + rebound_get["result"]["document"]["publication"]["farm_state"], + "not_published" + ); +} + +#[test] +fn farm_rebind_allows_watch_only_target_and_attach_secret_recovers_publish() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + sandbox.json_success(&[ + "--format", + "json", + "farm", + "create", + "--name", + "Watch Rebind Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + let watch_identity = identity_secret(56); + let watch_public = watch_identity.to_public(); + let public_identity_file = + write_public_identity_profile(&sandbox, "watch-rebind-public", &watch_public); + let secret_identity_file = + write_secret_identity_profile(&sandbox, "watch-rebind-secret", &watch_identity); + let imported = sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "import", + public_identity_file.to_string_lossy().as_ref(), + ]); + let watch_account_id = imported["result"]["account"]["id"] + .as_str() + .expect("watch account id"); + assert_eq!(imported["result"]["account"]["custody"], "watch_only"); + + let rebound = sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "farm", + "rebind", + watch_account_id, + ]); + assert_eq!(rebound["operation_id"], "farm.rebind"); + assert_eq!( + rebound["result"]["config"]["seller_account_id"], + watch_account_id + ); + + let readiness = sandbox.json_success(&[ + "--format", + "json", + "--relay", + "ws://127.0.0.1:9", + "farm", + "readiness", + "check", + ]); + assert_eq!(readiness["operation_id"], "farm.readiness.check"); + assert_eq!(readiness["result"]["publish_state"], "unconfigured"); + assert_eq!( + readiness["result"]["missing"][0], + "Write-capable farm-bound seller account" + ); + assert_action_present( + &readiness, + format!("radroots account attach-secret {watch_account_id} <path>").as_str(), + ); + + let (publish_output, publish) = sandbox.json_output(&[ + "--format", + "json", + "--relay", + "ws://127.0.0.1:9", + "--dry-run", + "farm", + "publish", + ]); + assert!(!publish_output.status.success()); + assert_eq!(publish["operation_id"], "farm.publish"); + assert_eq!(publish["errors"][0]["code"], "account_watch_only"); + + sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "attach-secret", + watch_account_id, + secret_identity_file.to_string_lossy().as_ref(), + ]); + let recovered = sandbox.json_success(&[ + "--format", + "json", + "--relay", + "ws://127.0.0.1:9", + "--dry-run", + "farm", + "publish", + ]); + assert_eq!(recovered["operation_id"], "farm.publish"); + assert_eq!(recovered["result"]["state"], "dry_run"); + assert_eq!(recovered["result"]["seller_account_id"], watch_account_id); + assert_eq!( + recovered["result"]["seller_pubkey"], + watch_public.public_key_hex + ); +} + +#[test] fn local_seller_publish_commands_attempt_configured_direct_relay() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]); diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -3341,6 +3341,11 @@ fn required_approval_token_rejects_absent_empty_and_whitespace_values() { "account.remove", &["account", "remove", "acct_missing"], ); + assert_required_approval_token_rejected( + &sandbox, + "farm.rebind", + &["farm", "rebind", "acct_missing"], + ); assert_required_approval_token_rejected(&sandbox, "farm.publish", &["farm", "publish"]); assert_required_approval_token_rejected( &sandbox,