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:
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,