cli

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

commit 2a3643e65a1e8df98d6a99ecb263e1d908e4777f
parent 0570a2c155ab3488a66077fa1fbbf1113a606c4c
Author: triesap <tyson@radroots.org>
Date:   Sat,  9 May 2026 18:29:04 +0000

cli: bind listing drafts to seller actors

Diffstat:
Msrc/domain/runtime.rs | 58+++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/main.rs | 3+++
Msrc/operation_adapter.rs | 48++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/operation_listing.rs | 55++++++++++++++++++++++++++++++++++++++++---------------
Msrc/operation_registry.rs | 20+++++++++++++++++++-
Msrc/runtime/accounts.rs | 12++++++++++++
Msrc/runtime/listing.rs | 637++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Msrc/runtime_args.rs | 7+++++++
Msrc/target_cli.rs | 40+++++++++++++++++++++++++++++++++++++++-
Mtests/signer_runtime_modes.rs | 46++++++++++++++++++++++++++++++++++++++++++++--
Mtests/support/mod.rs | 18+++++++++++++++---
Mtests/target_cli.rs | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
12 files changed, 930 insertions(+), 173 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -2260,10 +2260,12 @@ pub struct ListingNewView { #[serde(skip_serializing_if = "Option::is_none")] pub key: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] - pub selected_account_id: Option<String>, + pub seller_account_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub seller_pubkey: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] + pub seller_actor_source: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] pub farm_d_tag: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub delivery_method: Option<String>, @@ -2294,8 +2296,12 @@ pub struct ListingValidateView { #[serde(skip_serializing_if = "Option::is_none")] pub listing_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] + pub seller_account_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] pub seller_pubkey: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] + pub seller_actor_source: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] pub farm_d_tag: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub issues: Vec<ListingValidationIssueView>, @@ -2343,8 +2349,12 @@ pub struct ListingSummaryView { #[serde(skip_serializing_if = "Option::is_none")] pub category: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] + pub seller_account_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] pub seller_pubkey: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] + pub seller_actor_source: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] pub farm_d_tag: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub location_primary: Option<String>, @@ -2558,7 +2568,9 @@ pub struct ListingMutationView { pub file: String, pub listing_id: String, pub listing_addr: String, + pub seller_account_id: String, pub seller_pubkey: String, + pub seller_actor_source: String, pub event_kind: u32, #[serde(default)] pub dry_run: bool, @@ -2612,6 +2624,50 @@ impl ListingMutationView { } #[derive(Debug, Clone, Serialize)] +pub struct ListingRebindView { + pub state: String, + pub source: String, + pub file: String, + pub listing_id: String, + pub dry_run: bool, + #[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 from_seller_actor_source: Option<String>, + pub to_seller_account_id: String, + pub to_seller_pubkey: String, + pub to_seller_actor_source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub seller_pubkey_changed: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] + pub from_listing_addr: Option<String>, + pub to_listing_addr: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_addr_changed: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] + pub from_farm_d_tag: Option<String>, + pub to_farm_d_tag: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub farm_d_tag_changed: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl ListingRebindView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "unconfigured" => CommandDisposition::Unconfigured, + "error" => CommandDisposition::InternalError, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] pub struct ListingMutationLocalReplicaView { pub state: String, pub store_state: String, diff --git a/src/main.rs b/src/main.rs @@ -231,6 +231,9 @@ fn execute_request( TargetOperationRequest::ListingValidate(request) => { execute_with(ListingOperationService::new(config), request) } + TargetOperationRequest::ListingRebind(request) => { + execute_with(ListingOperationService::new(config), request) + } TargetOperationRequest::ListingPublish(request) => { execute_with(ListingOperationService::new(config), request) } diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -1386,6 +1386,11 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati | ListingCommand::Validate(args) | ListingCommand::Publish(args) | ListingCommand::Archive(args) => insert_path(&mut input, "file", &args.file), + ListingCommand::Rebind(args) => { + insert_path(&mut input, "file", &args.file); + insert_string(&mut input, "selector", &args.selector); + insert_string(&mut input, "farm_d_tag", &args.farm_d_tag); + } ListingCommand::List => {} }, TargetCommand::Market(args) => match &args.command { @@ -1619,6 +1624,7 @@ target_operation_contracts! { ListingList => (ListingListRequest, ListingListResult, "listing.list"), ListingUpdate => (ListingUpdateRequest, ListingUpdateResult, "listing.update"), ListingValidate => (ListingValidateRequest, ListingValidateResult, "listing.validate"), + ListingRebind => (ListingRebindRequest, ListingRebindResult, "listing.rebind"), ListingPublish => (ListingPublishRequest, ListingPublishResult, "listing.publish"), ListingArchive => (ListingArchiveRequest, ListingArchiveResult, "listing.archive"), MarketRefresh => (MarketRefreshRequest, MarketRefreshResult, "market.refresh"), @@ -1818,6 +1824,48 @@ mod tests { } #[test] + fn adapter_maps_listing_rebind_inputs() { + let parsed = TargetCliArgs::try_parse_from([ + "radroots", + "listing", + "rebind", + "listing.toml", + "acct_test", + "--farm-d-tag", + "AAAAAAAAAAAAAAAAAAAAAw", + ]) + .expect("target args parse"); + + let request = TargetOperationRequest::from_target_args(&parsed) + .expect("operation request from target args"); + let TargetOperationRequest::ListingRebind(request) = request else { + panic!("expected listing rebind request") + }; + + assert_eq!(request.operation_id(), "listing.rebind"); + assert_eq!( + request.payload.input.get("file").and_then(Value::as_str), + Some("listing.toml") + ); + assert_eq!( + request + .payload + .input + .get("selector") + .and_then(Value::as_str), + Some("acct_test") + ); + assert_eq!( + request + .payload + .input + .get("farm_d_tag") + .and_then(Value::as_str), + Some("AAAAAAAAAAAAAAAAAAAAAw") + ); + } + + #[test] fn adapter_maps_order_fulfillment_update_input() { let parsed = TargetCliArgs::try_parse_from([ "radroots", diff --git a/src/operation_listing.rs b/src/operation_listing.rs @@ -7,15 +7,15 @@ use crate::domain::runtime::{CommandDisposition, ListingMutationView}; use crate::operation_adapter::{ ListingArchiveRequest, ListingArchiveResult, ListingCreateRequest, ListingCreateResult, ListingGetRequest, ListingGetResult, ListingListRequest, ListingListResult, - ListingPublishRequest, ListingPublishResult, ListingUpdateRequest, ListingUpdateResult, - ListingValidateRequest, ListingValidateResult, OperationAdapterError, OperationRequest, - OperationRequestData, OperationRequestPayload, OperationResult, OperationResultData, - OperationService, + ListingPublishRequest, ListingPublishResult, ListingRebindRequest, ListingRebindResult, + ListingUpdateRequest, ListingUpdateResult, ListingValidateRequest, ListingValidateResult, + OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, + OperationResult, OperationResultData, OperationService, }; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; use crate::runtime_args::{ - ListingCreateArgs, ListingFileArgs, ListingMutationArgs, RecordLookupArgs, + ListingCreateArgs, ListingFileArgs, ListingMutationArgs, ListingRebindArgs, RecordLookupArgs, }; pub struct ListingOperationService<'a> { @@ -144,6 +144,34 @@ impl OperationService<ListingValidateRequest> for ListingOperationService<'_> { } } +impl OperationService<ListingRebindRequest> for ListingOperationService<'_> { + type Result = ListingRebindResult; + + fn execute( + &self, + request: OperationRequest<ListingRebindRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let args = ListingRebindArgs { + file: required_path(&request, "file")?, + selector: required_string(&request, "selector")?, + farm_d_tag: string_input(&request, "farm_d_tag"), + }; + if request.context.dry_run { + let view = map_runtime( + request.operation_id(), + crate::runtime::listing::rebind_preflight(self.config, &args), + )?; + return serialized_operation_result::<ListingRebindResult, _>(&view); + } + require_approval(&request)?; + let view = map_runtime( + request.operation_id(), + crate::runtime::listing::rebind(self.config, &args), + )?; + serialized_operation_result::<ListingRebindResult, _>(&view) + } +} + impl OperationService<ListingPublishRequest> for ListingOperationService<'_> { type Result = ListingPublishResult; @@ -367,7 +395,7 @@ mod tests { }; #[test] - fn listing_service_supports_create_dry_run_without_sell_path() { + fn listing_service_requires_seller_actor_for_create_dry_run() { let dir = tempdir().expect("tempdir"); let config = sample_config(dir.path()); let service = OperationAdapter::new(ListingOperationService::new(&config)); @@ -378,16 +406,13 @@ mod tests { ListingCreateRequest::from_data(data(&[("key", "eggs"), ("title", "Eggs")])), ) .expect("listing create request"); - let envelope = service + let error = service .execute(request) - .expect("listing create result") - .to_envelope(context.envelope_context("req_listing_create")) - .expect("listing create envelope"); - - assert_eq!(envelope.operation_id, "listing.create"); - assert_eq!(envelope.dry_run, true); - assert_eq!(envelope.result["state"], "dry_run"); - assert_eq!(envelope.result["key"], "eggs"); + .expect_err("listing create seller actor"); + let output_error = error.to_output_error(); + + assert_eq!(output_error.code, "account_unresolved"); + assert!(output_error.detail.expect("detail")["seller_actor_source"] == "resolved_account"); } #[test] diff --git a/src/operation_registry.rs b/src/operation_registry.rs @@ -638,6 +638,21 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ false ), operation!( + "listing.rebind", + "radroots listing rebind", + "listing", + "listing_rebind", + "ListingRebindRequest", + "ListingRebindResult", + "Rebind a listing draft to an explicit seller actor.", + Seller, + true, + Required, + High, + false, + true + ), + operation!( "listing.publish", "radroots listing publish", "listing", @@ -1239,6 +1254,7 @@ mod tests { "listing.list", "listing.update", "listing.validate", + "listing.rebind", "listing.publish", "listing.archive", "market.refresh", @@ -1293,6 +1309,7 @@ mod tests { "farm.publish", "listing.create", "listing.update", + "listing.rebind", "listing.publish", "listing.archive", "market.refresh", @@ -1324,7 +1341,7 @@ mod tests { .copied() .collect::<BTreeSet<_>>(); assert_eq!(actual, expected); - assert_eq!(OPERATION_REGISTRY.len(), 69); + assert_eq!(OPERATION_REGISTRY.len(), 70); } #[test] @@ -1371,6 +1388,7 @@ mod tests { "sync.push", "farm.rebind", "farm.publish", + "listing.rebind", "listing.publish", "listing.archive", "order.submit", diff --git a/src/runtime/accounts.rs b/src/runtime/accounts.rs @@ -70,6 +70,18 @@ impl AccountRuntimeFailure { ))) } + pub fn watch_only_with_detail( + account_id: impl fmt::Display, + detail: serde_json::Value, + ) -> Self { + Self::WatchOnly(AccountRuntimeFailureIssue::with_detail( + format!( + "resolved account `{account_id}` is watch_only and cannot sign because it is not secret-backed" + ), + detail, + )) + } + pub fn mismatch(message: impl Into<String>) -> Self { Self::Mismatch(AccountRuntimeFailureIssue::new(message)) } diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -38,8 +38,8 @@ use serde_json::json; use crate::domain::runtime::{ FindPriceView, FindQuantityView, FindResultProvenanceView, ListingGetView, ListingListView, ListingMutationEventView, ListingMutationJobView, ListingMutationLocalReplicaView, - ListingMutationView, ListingNewView, ListingSummaryView, ListingValidateView, - ListingValidationIssueView, RelayFailureView, SyncFreshnessView, + ListingMutationView, ListingNewView, ListingRebindView, ListingSummaryView, + ListingValidateView, ListingValidationIssueView, RelayFailureView, SyncFreshnessView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts; @@ -54,7 +54,7 @@ use crate::runtime::farm_config; use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority}; use crate::runtime::sync::freshness_from_executor; use crate::runtime_args::{ - ListingCreateArgs, ListingFileArgs, ListingMutationArgs, RecordLookupArgs, + ListingCreateArgs, ListingFileArgs, ListingMutationArgs, ListingRebindArgs, RecordLookupArgs, }; const DRAFT_KIND: &str = "listing_draft_v1"; @@ -64,6 +64,9 @@ const RELAY_LISTING_WRITE_SOURCE: &str = "direct Nostr relay publish · local ke const RADROOTSD_LISTING_WRITE_SOURCE: &str = "radrootsd publish transport · signer session"; const RADROOTSD_BRIDGE_LISTING_PUBLISH_METHOD: &str = "bridge.listing.publish"; const LISTING_DRAFTS_DIR: &str = "listings/drafts"; +const LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG: &str = "farm_config"; +const LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT: &str = "resolved_account"; +const LISTING_SELLER_ACTOR_SOURCE_REBIND: &str = "listing_rebind"; static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -73,6 +76,7 @@ struct ListingDraftDocument { version: u32, kind: String, listing: ListingDraftMeta, + seller_actor: ListingDraftSellerActor, product: ListingDraftProduct, primary_bin: ListingDraftPrimaryBin, inventory: ListingDraftInventory, @@ -88,7 +92,14 @@ struct ListingDraftDocument { struct ListingDraftMeta { d_tag: String, farm_d_tag: String, - seller_pubkey: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ListingDraftSellerActor { + account_id: String, + pubkey: String, + source: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -172,9 +183,6 @@ struct ListingDraftDiscount { #[derive(Debug, Clone)] struct ListingValidationContext { - selected_account_id: Option<String>, - selected_account_pubkey: Option<String>, - selected_farm_d_tag: Option<String>, farm_setup_action: String, } @@ -185,8 +193,9 @@ struct ListingAuthoringDefaults { farm_next_action: Option<String>, farm_reason: Option<String>, farm_name: Option<String>, - selected_account_id: Option<String>, - selected_account_pubkey: Option<String>, + seller_account_id: String, + seller_pubkey: String, + seller_actor_source: String, selected_farm_d_tag: Option<String>, delivery_method: Option<String>, location: Option<ListingDraftLocation>, @@ -195,7 +204,9 @@ struct ListingAuthoringDefaults { #[derive(Debug, Clone)] struct CanonicalListingDraft { listing_id: String, + seller_account_id: String, seller_pubkey: String, + seller_actor_source: String, farm_d_tag: String, listing: RadrootsListing, } @@ -263,9 +274,6 @@ pub fn scaffold( "radroots listing validate {}", output_path.display() )]; - if defaults.selected_account_pubkey.is_none() { - actions.push("radroots account create".to_owned()); - } if let Some(action) = &defaults.farm_next_action { actions.push(action.clone()); } @@ -276,8 +284,9 @@ pub fn scaffold( file: output_path.display().to_string(), listing_id: draft.listing.d_tag, key: non_empty(draft.product.key.clone()), - selected_account_id: defaults.selected_account_id, - seller_pubkey: defaults.selected_account_pubkey, + seller_account_id: Some(defaults.seller_account_id), + seller_pubkey: Some(defaults.seller_pubkey), + seller_actor_source: Some(defaults.seller_actor_source), farm_d_tag: defaults.selected_farm_d_tag, delivery_method: non_empty(draft.delivery.method.clone()), location_primary: non_empty(draft.location.primary.clone()), @@ -298,9 +307,6 @@ pub fn scaffold_preflight( "radroots listing validate {}", output_path.display() )]; - if defaults.selected_account_pubkey.is_none() { - actions.push("radroots account create".to_owned()); - } if let Some(action) = &defaults.farm_next_action { actions.push(action.clone()); } @@ -311,8 +317,9 @@ pub fn scaffold_preflight( file: output_path.display().to_string(), listing_id: draft.listing.d_tag, key: non_empty(draft.product.key.clone()), - selected_account_id: defaults.selected_account_id, - seller_pubkey: defaults.selected_account_pubkey, + seller_account_id: Some(defaults.seller_account_id), + seller_pubkey: Some(defaults.seller_pubkey), + seller_actor_source: Some(defaults.seller_actor_source), farm_d_tag: defaults.selected_farm_d_tag, delivery_method: non_empty(draft.delivery.method.clone()), location_primary: non_empty(draft.location.primary.clone()), @@ -333,7 +340,11 @@ fn build_listing_draft( listing: ListingDraftMeta { d_tag: generate_d_tag(), farm_d_tag: defaults.selected_farm_d_tag.clone().unwrap_or_default(), - seller_pubkey: defaults.selected_account_pubkey.clone().unwrap_or_default(), + }, + seller_actor: ListingDraftSellerActor { + account_id: defaults.seller_account_id.clone(), + pubkey: defaults.seller_pubkey.clone(), + source: defaults.seller_actor_source.clone(), }, product: ListingDraftProduct { key: args.key.clone().unwrap_or_default(), @@ -481,8 +492,10 @@ pub fn validate( file: args.file.display().to_string(), valid: false, listing_id: None, - seller_pubkey: context.selected_account_pubkey.clone(), - farm_d_tag: context.selected_farm_d_tag.clone(), + seller_account_id: None, + seller_pubkey: None, + seller_actor_source: None, + farm_d_tag: None, issues: vec![ListingValidationIssueView { field: "toml".to_owned(), message: error.to_string(), @@ -502,7 +515,7 @@ pub fn validate( Err(error) => { return Ok(invalid_validation_view( args.file.as_path(), - parsed.listing.d_tag.as_str(), + &parsed, &context, ListingValidationIssueView { field: "listing".to_owned(), @@ -512,6 +525,14 @@ pub fn validate( )); } }; + if let Some(issue) = listing_bound_account_issue(config, &canonical, &contents)? { + return Ok(invalid_validation_view( + args.file.as_path(), + &parsed, + &context, + issue, + )); + } let event = RadrootsNostrEvent { id: String::new(), author: canonical.seller_pubkey.clone(), @@ -528,14 +549,16 @@ pub fn validate( file: args.file.display().to_string(), valid: true, listing_id: Some(canonical.listing_id), + seller_account_id: Some(canonical.seller_account_id), seller_pubkey: Some(canonical.seller_pubkey), + seller_actor_source: Some(canonical.seller_actor_source), farm_d_tag: Some(canonical.farm_d_tag), issues: Vec::new(), actions: vec![format!("radroots listing publish {}", args.file.display())], }), Err(error) => Ok(invalid_validation_view( args.file.as_path(), - parsed.listing.d_tag.as_str(), + &parsed, &context, issue_from_trade_validation(error, &contents), )), @@ -543,7 +566,7 @@ pub fn validate( } Err(error) => Ok(invalid_validation_view( args.file.as_path(), - parsed.listing.d_tag.as_str(), + &parsed, &context, error.into_issue(), )), @@ -572,7 +595,7 @@ pub fn list(config: &RuntimeConfig) -> Result<ListingListView, RuntimeError> { continue; } match load_listing_draft(path.as_path()) { - Ok(loaded) => listings.push(summary_from_loaded(&loaded, context.as_ref())), + Ok(loaded) => listings.push(summary_from_loaded(config, &loaded, context.as_ref())), Err(issue) => listings.push(summary_for_invalid_file(path.as_path(), issue)), } } @@ -607,6 +630,174 @@ pub fn list(config: &RuntimeConfig) -> Result<ListingListView, RuntimeError> { }) } +pub fn rebind( + config: &RuntimeConfig, + args: &ListingRebindArgs, +) -> Result<ListingRebindView, RuntimeError> { + rebind_inner(config, args, false) +} + +pub fn rebind_preflight( + config: &RuntimeConfig, + args: &ListingRebindArgs, +) -> Result<ListingRebindView, RuntimeError> { + rebind_inner(config, args, true) +} + +fn rebind_inner( + config: &RuntimeConfig, + args: &ListingRebindArgs, + dry_run: bool, +) -> Result<ListingRebindView, RuntimeError> { + let contents = fs::read_to_string(&args.file)?; + let mut draft = toml::from_str::<ListingDraftDocument>(&contents).map_err(|error| { + RuntimeError::Config(format!( + "invalid listing draft {}: {error}", + args.file.display() + )) + })?; + let listing_id = draft.listing.d_tag.trim().to_owned(); + if !is_d_tag_base64url(&listing_id) { + return Err(RuntimeError::Config(format!( + "invalid listing draft {}: listing d_tag must be a 22-character base64url identifier", + args.file.display() + ))); + } + + let target_account = accounts::resolve_account_selector(config, args.selector.as_str()) + .map_err(|error| listing_rebind_selector_error(args.selector.as_str(), error))?; + let from_seller_account_id = non_empty(draft.seller_actor.account_id.clone()); + let from_seller_pubkey = non_empty(draft.seller_actor.pubkey.clone()); + let from_seller_actor_source = non_empty(draft.seller_actor.source.clone()); + let from_farm_d_tag = non_empty(draft.listing.farm_d_tag.clone()); + let target_account_id = target_account.record.account_id.to_string(); + let target_pubkey = target_account.record.public_identity.public_key_hex.clone(); + let target_farm_d_tag = resolve_rebind_farm_d_tag( + config, + args, + from_seller_account_id.as_deref(), + from_farm_d_tag.as_deref(), + target_account_id.as_str(), + )?; + let from_listing_addr = from_seller_pubkey + .as_ref() + .map(|pubkey| listing_addr(pubkey, listing_id.as_str())); + let to_listing_addr = listing_addr(target_pubkey.as_str(), listing_id.as_str()); + let seller_pubkey_changed = from_seller_pubkey + .as_deref() + .map(|pubkey| !pubkey.eq_ignore_ascii_case(target_pubkey.as_str())); + let listing_addr_changed = from_listing_addr + .as_deref() + .map(|addr| addr != to_listing_addr.as_str()); + let farm_d_tag_changed = from_farm_d_tag + .as_deref() + .map(|d_tag| d_tag != target_farm_d_tag.as_str()); + + draft.seller_actor.account_id = target_account_id.clone(); + draft.seller_actor.pubkey = target_pubkey.clone(); + draft.seller_actor.source = LISTING_SELLER_ACTOR_SOURCE_REBIND.to_owned(); + draft.listing.farm_d_tag = target_farm_d_tag.clone(); + + if !dry_run { + write_listing_draft(args.file.as_path(), &draft, true)?; + } + + Ok(ListingRebindView { + state: if dry_run { "dry_run" } else { "rebound" }.to_owned(), + source: LISTING_SOURCE.to_owned(), + file: args.file.display().to_string(), + listing_id, + dry_run, + from_seller_account_id, + from_seller_pubkey, + from_seller_actor_source, + to_seller_account_id: target_account_id, + to_seller_pubkey: target_pubkey, + to_seller_actor_source: LISTING_SELLER_ACTOR_SOURCE_REBIND.to_owned(), + seller_pubkey_changed, + from_listing_addr, + to_listing_addr, + listing_addr_changed, + from_farm_d_tag, + to_farm_d_tag: target_farm_d_tag, + farm_d_tag_changed, + reason: Some(if dry_run { + "dry run requested; listing seller actor binding was not written".to_owned() + } else { + "listing seller actor binding updated".to_owned() + }), + actions: if dry_run { + vec![format!( + "radroots --approval-token approve listing rebind {} {}", + args.file.display(), + args.selector + )] + } else { + vec![format!("radroots listing validate {}", args.file.display())] + }, + }) +} + +fn resolve_rebind_farm_d_tag( + config: &RuntimeConfig, + args: &ListingRebindArgs, + from_seller_account_id: Option<&str>, + from_farm_d_tag: Option<&str>, + target_account_id: &str, +) -> Result<String, RuntimeError> { + if let Some(explicit) = args + .farm_d_tag + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if !is_d_tag_base64url(explicit) { + return Err(RuntimeError::Config( + "listing rebind --farm-d-tag must be a 22-character base64url identifier" + .to_owned(), + )); + } + return Ok(explicit.to_owned()); + } + if from_seller_account_id == Some(target_account_id) + && let Some(existing) = from_farm_d_tag + { + return Ok(existing.to_owned()); + } + if let Some(resolved) = farm_config::load(config, None)? + && resolved.document.selection.account == target_account_id + { + return Ok(resolved.document.selection.farm_d_tag); + } + Err(RuntimeError::Config(format!( + "listing rebind requires --farm-d-tag when target account `{target_account_id}` is not bound by the selected farm config" + ))) +} + +fn listing_rebind_selector_error(selector: &str, error: RuntimeError) -> RuntimeError { + match error { + RuntimeError::Account(accounts::AccountRuntimeFailure::Unresolved(issue)) => { + accounts::AccountRuntimeFailure::unresolved_with_detail( + issue.message().to_owned(), + json!({ + "seller_actor_source": LISTING_SELLER_ACTOR_SOURCE_REBIND, + "selector": selector, + "actions": [ + "radroots account import <path>", + "radroots account create", + ], + }), + ) + .into() + } + other => other, + } +} + +fn listing_addr(seller_pubkey: &str, listing_id: &str) -> String { + format!("{KIND_LISTING}:{seller_pubkey}:{listing_id}") +} + fn load_listing_draft(path: &Path) -> Result<LoadedListingDraft, ListingValidationIssueView> { let contents = fs::read_to_string(path).map_err(|error| ListingValidationIssueView { field: "file".to_owned(), @@ -631,10 +822,13 @@ fn load_listing_draft(path: &Path) -> Result<LoadedListingDraft, ListingValidati } fn summary_from_loaded( + config: &RuntimeConfig, loaded: &LoadedListingDraft, context: Result<&ListingValidationContext, &String>, ) -> ListingSummaryView { - let mut seller_pubkey = non_empty(loaded.document.listing.seller_pubkey.clone()); + let mut seller_account_id = non_empty(loaded.document.seller_actor.account_id.clone()); + let mut seller_pubkey = non_empty(loaded.document.seller_actor.pubkey.clone()); + let mut seller_actor_source = non_empty(loaded.document.seller_actor.source.clone()); let mut farm_d_tag = non_empty(loaded.document.listing.farm_d_tag.clone()); let mut issues = Vec::new(); let mut state = "draft"; @@ -643,9 +837,16 @@ fn summary_from_loaded( Ok(context) => { match canonicalize_draft(&loaded.document, loaded.contents.as_str(), context) { Ok(canonical) => { + seller_account_id = Some(canonical.seller_account_id.clone()); seller_pubkey = Some(canonical.seller_pubkey.clone()); + seller_actor_source = Some(canonical.seller_actor_source.clone()); farm_d_tag = Some(canonical.farm_d_tag.clone()); issues = listing_ready_issues(&canonical, loaded.contents.as_str()); + if let Ok(Some(issue)) = + listing_bound_account_issue(config, &canonical, loaded.contents.as_str()) + { + issues.push(issue); + } if issues.is_empty() { state = "ready"; } @@ -668,7 +869,9 @@ fn summary_from_loaded( product_key: non_empty(loaded.document.product.key.clone()), title: non_empty(loaded.document.product.title.clone()), category: non_empty(loaded.document.product.category.clone()), + seller_account_id, seller_pubkey, + seller_actor_source, farm_d_tag, location_primary: non_empty(loaded.document.location.primary.clone()), updated_at_unix: loaded.updated_at_unix, @@ -713,7 +916,9 @@ fn summary_for_invalid_file(path: &Path, issue: ListingValidationIssueView) -> L product_key: None, title: None, category: None, + seller_account_id: None, seller_pubkey: None, + seller_actor_source: None, farm_d_tag: None, location_primary: None, updated_at_unix: modified_unix(path).unwrap_or_default(), @@ -856,10 +1061,14 @@ fn mutate( let mut canonical = canonicalize_draft(&parsed, &contents, &context).map_err(|error| { let issue = match error { ListingDraftValidationError::MissingSellerAccount(issue) => { - return accounts::AccountRuntimeFailure::unresolved(format!( - "{} ({})", - issue.message, issue.field - )) + return accounts::AccountRuntimeFailure::unresolved_with_detail( + format!("{} ({})", issue.message, issue.field), + json!({ + "seller_actor_source": "listing_draft", + "listing_file": args.file.display().to_string(), + "actions": listing_bound_account_recovery_actions(args.file.as_path()), + }), + ) .into(); } ListingDraftValidationError::Issue(issue) => issue, @@ -871,6 +1080,7 @@ fn mutate( issue.field )) })?; + ensure_listing_bound_account(config, &canonical, args.file.as_path())?; if matches!(operation, ListingMutationOperation::Archive) { canonical.listing.availability = Some(RadrootsListingAvailability::Status { @@ -928,7 +1138,9 @@ fn mutate( file: args.file.display().to_string(), listing_id: canonical.listing_id.clone(), listing_addr: listing_addr.clone(), + seller_account_id: canonical.seller_account_id.clone(), seller_pubkey: canonical.seller_pubkey.clone(), + seller_actor_source: canonical.seller_actor_source.clone(), event_kind: KIND_LISTING, dry_run: true, deduplicated: false, @@ -1203,6 +1415,18 @@ fn radrootsd_mutation_view( .event_addr .as_deref() .and_then(daemon_listing_identity); + if let Some(identity) = daemon_identity.as_ref() + && (!identity + .seller_pubkey + .eq_ignore_ascii_case(canonical.seller_pubkey.as_str()) + || identity.listing_id != canonical.listing_id) + { + return Err(RuntimeError::Config(format!( + "radrootsd listing publish returned event_addr identity `{}` that does not match listing draft `{}`", + radrootsd.event_addr.as_deref().unwrap_or_default(), + listing_addr + ))); + } let event_addr = radrootsd .event_addr .clone() @@ -1212,10 +1436,7 @@ fn radrootsd_mutation_view( .as_ref() .map(|identity| identity.listing_id.clone()) .unwrap_or_else(|| canonical.listing_id.clone()); - let seller_pubkey = daemon_identity - .as_ref() - .map(|identity| identity.seller_pubkey.clone()) - .unwrap_or_else(|| canonical.seller_pubkey.clone()); + let seller_pubkey = canonical.seller_pubkey.clone(); event.author = seller_pubkey.clone(); let job_status = radrootsd.status.clone(); let state = match operation { @@ -1232,7 +1453,9 @@ fn radrootsd_mutation_view( file: args.file.display().to_string(), listing_id, listing_addr: event_addr.clone(), + seller_account_id: canonical.seller_account_id.clone(), seller_pubkey, + seller_actor_source: canonical.seller_actor_source.clone(), event_kind: event_kind.unwrap_or(KIND_LISTING), dry_run: false, deduplicated: radrootsd.deduplicated, @@ -1327,21 +1550,7 @@ fn scaffold_contents(draft: &ListingDraftDocument) -> Result<String, RuntimeErro } fn validation_context(config: &RuntimeConfig) -> Result<ListingValidationContext, RuntimeError> { - let defaults = authoring_defaults(config)?; - let selected_farm_d_tag = match ( - defaults.farm_config_present, - defaults.selected_farm_d_tag, - defaults.selected_account_pubkey.clone(), - ) { - (true, d_tag, _) => d_tag, - (false, Some(d_tag), _) => Some(d_tag), - (false, None, Some(pubkey)) => resolve_selected_farm_d_tag(config, pubkey.as_str())?, - (false, None, None) => None, - }; Ok(ListingValidationContext { - selected_account_id: defaults.selected_account_id, - selected_account_pubkey: defaults.selected_account_pubkey, - selected_farm_d_tag, farm_setup_action: farm_setup_action(config)?, }) } @@ -1359,9 +1568,6 @@ fn radrootsd_mutation_validation_context( config: &RuntimeConfig, ) -> Result<ListingValidationContext, RuntimeError> { Ok(ListingValidationContext { - selected_account_id: None, - selected_account_pubkey: None, - selected_farm_d_tag: None, farm_setup_action: farm_setup_action(config)?, }) } @@ -1369,7 +1575,7 @@ fn radrootsd_mutation_validation_context( fn canonicalize_draft( draft: &ListingDraftDocument, contents: &str, - context: &ListingValidationContext, + _context: &ListingValidationContext, ) -> Result<CanonicalListingDraft, ListingDraftValidationError> { if draft.version != 1 { return Err(issue_for_field( @@ -1398,31 +1604,62 @@ fn canonicalize_draft( .into()); } - let seller_pubkey = if let Some(pubkey) = non_empty(draft.listing.seller_pubkey.clone()) { - pubkey - } else if let Some(pubkey) = context.selected_account_pubkey.clone() { + let seller_account_id = + if let Some(account_id) = non_empty(draft.seller_actor.account_id.clone()) { + account_id + } else { + return Err(ListingDraftValidationError::MissingSellerAccount( + issue_for_field( + contents, + "seller_actor.account_id", + "missing listing seller_actor account_id", + ), + )); + }; + + let seller_pubkey = if let Some(pubkey) = non_empty(draft.seller_actor.pubkey.clone()) { pubkey } else { return Err(ListingDraftValidationError::MissingSellerAccount( issue_for_field( contents, - "listing.seller_pubkey", - "missing seller_pubkey and no resolved account pubkey is available", + "seller_actor.pubkey", + "missing listing seller_actor pubkey", ), )); }; - let farm_d_tag = if let Some(d_tag) = non_empty(draft.listing.farm_d_tag.clone()) { - d_tag - } else if let Some(d_tag) = context.selected_farm_d_tag.clone() { - d_tag + let seller_actor_source = if let Some(source) = non_empty(draft.seller_actor.source.clone()) { + source } else { + return Err(ListingDraftValidationError::MissingSellerAccount( + issue_for_field( + contents, + "seller_actor.source", + "missing listing seller_actor source", + ), + )); + }; + if !matches!( + seller_actor_source.as_str(), + LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG + | LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT + | LISTING_SELLER_ACTOR_SOURCE_REBIND + ) { return Err(issue_for_field( contents, - "listing.farm_d_tag", - "missing farm_d_tag and no selected farm config is available", + "seller_actor.source", + format!("unsupported listing seller_actor source `{seller_actor_source}`"), ) .into()); + } + + let farm_d_tag = if let Some(d_tag) = non_empty(draft.listing.farm_d_tag.clone()) { + d_tag + } else { + return Err( + issue_for_field(contents, "listing.farm_d_tag", "missing listing farm_d_tag").into(), + ); }; if !is_d_tag_base64url(&farm_d_tag) { return Err(issue_for_field( @@ -1542,7 +1779,9 @@ fn canonicalize_draft( Ok(CanonicalListingDraft { listing_id, + seller_account_id, seller_pubkey, + seller_actor_source, farm_d_tag, listing, }) @@ -1725,17 +1964,134 @@ fn build_listing_discounts( Ok((!discounts.is_empty()).then_some(discounts)) } +fn listing_bound_account_issue( + config: &RuntimeConfig, + canonical: &CanonicalListingDraft, + contents: &str, +) -> Result<Option<ListingValidationIssueView>, RuntimeError> { + let Some(account) = configured_account(config, &canonical.seller_account_id)? else { + return Ok(Some(issue_for_field( + contents, + "seller_actor.account_id", + format!( + "listing seller_actor account_id `{}` is not present in the local account store", + canonical.seller_account_id + ), + ))); + }; + let account_pubkey = account.record.public_identity.public_key_hex; + if !account_pubkey.eq_ignore_ascii_case(canonical.seller_pubkey.as_str()) { + return Ok(Some(issue_for_field( + contents, + "seller_actor.pubkey", + format!( + "listing seller_actor pubkey `{}` does not match account `{}` pubkey `{account_pubkey}`", + canonical.seller_pubkey, canonical.seller_account_id + ), + ))); + } + Ok(None) +} + +fn ensure_listing_bound_account( + config: &RuntimeConfig, + canonical: &CanonicalListingDraft, + file: &Path, +) -> Result<(), RuntimeError> { + validate_invocation_account_matches_bound(config, canonical, file)?; + let Some(account) = configured_account(config, &canonical.seller_account_id)? else { + return Err(accounts::AccountRuntimeFailure::unresolved_with_detail( + format!( + "listing-bound seller account `{}` is not present in the local account store", + canonical.seller_account_id + ), + json!({ + "seller_actor_source": canonical.seller_actor_source, + "listing_seller_account_id": canonical.seller_account_id, + "listing_file": file.display().to_string(), + "actions": listing_bound_account_recovery_actions(file), + }), + ) + .into()); + }; + let account_pubkey = account.record.public_identity.public_key_hex; + if !account_pubkey.eq_ignore_ascii_case(canonical.seller_pubkey.as_str()) { + return Err(accounts::AccountRuntimeFailure::mismatch_with_detail( + format!( + "account mismatch: listing-bound seller account `{}` pubkey `{account_pubkey}` cannot sign listing seller_pubkey `{}`", + canonical.seller_account_id, canonical.seller_pubkey + ), + json!({ + "seller_actor_source": canonical.seller_actor_source, + "listing_seller_account_id": canonical.seller_account_id, + "listing_seller_pubkey": canonical.seller_pubkey, + "account_pubkey": account_pubkey, + "listing_file": file.display().to_string(), + "actions": listing_bound_account_recovery_actions(file), + }), + ) + .into()); + } + Ok(()) +} + +fn validate_invocation_account_matches_bound( + config: &RuntimeConfig, + canonical: &CanonicalListingDraft, + file: &Path, +) -> Result<(), RuntimeError> { + let Some(selector) = config + .account + .selector + .as_deref() + .map(str::trim) + .filter(|selector| !selector.is_empty()) + else { + return Ok(()); + }; + let attempted = accounts::resolve_account_selector(config, selector)?; + if attempted.record.account_id.to_string() == canonical.seller_account_id { + return Ok(()); + } + Err(accounts::AccountRuntimeFailure::mismatch_with_detail( + format!( + "account mismatch: listing draft is bound to seller account `{}`; invocation selected `{}`", + canonical.seller_account_id, attempted.record.account_id + ), + json!({ + "seller_actor_source": canonical.seller_actor_source, + "listing_seller_account_id": canonical.seller_account_id, + "attempted_seller_account_id": attempted.record.account_id.to_string(), + "listing_file": file.display().to_string(), + "actions": listing_bound_account_recovery_actions(file), + }), + ) + .into()) +} + +fn listing_bound_account_recovery_actions(file: &Path) -> Vec<String> { + vec![ + "radroots account import <path>".to_owned(), + format!("radroots listing rebind {} <selector>", file.display()), + ] +} + fn invalid_validation_view( file: &Path, - listing_id: &str, + draft: &ListingDraftDocument, context: &ListingValidationContext, issue: ListingValidationIssueView, ) -> ListingValidateView { let mut actions = vec![format!("edit {}", file.display())]; - if context.selected_account_id.is_none() { + if draft.seller_actor.account_id.trim().is_empty() { actions.push("radroots account create".to_owned()); + } else { + actions.push(format!( + "radroots listing rebind {} <selector>", + file.display() + )); } - if context.selected_farm_d_tag.is_none() { + if draft.listing.farm_d_tag.trim().is_empty() { actions.push(context.farm_setup_action.clone()); } @@ -1744,9 +2100,11 @@ fn invalid_validation_view( source: LISTING_SOURCE.to_owned(), file: file.display().to_string(), valid: false, - listing_id: non_empty(listing_id.to_owned()), - seller_pubkey: context.selected_account_pubkey.clone(), - farm_d_tag: context.selected_farm_d_tag.clone(), + listing_id: non_empty(draft.listing.d_tag.clone()), + seller_account_id: non_empty(draft.seller_actor.account_id.clone()), + seller_pubkey: non_empty(draft.seller_actor.pubkey.clone()), + seller_actor_source: non_empty(draft.seller_actor.source.clone()), + farm_d_tag: non_empty(draft.listing.farm_d_tag.clone()), issues: vec![issue], actions, } @@ -1798,7 +2156,9 @@ fn radrootsd_preflight_view( file: args.file.display().to_string(), listing_id: canonical.listing_id.clone(), listing_addr: listing_addr.clone(), + seller_account_id: canonical.seller_account_id.clone(), seller_pubkey: canonical.seller_pubkey.clone(), + seller_actor_source: canonical.seller_actor_source.clone(), event_kind: KIND_LISTING, dry_run: false, deduplicated: false, @@ -1843,7 +2203,9 @@ fn direct_relay_error_view( file: args.file.display().to_string(), listing_id: canonical.listing_id.clone(), listing_addr: listing_addr.clone(), + seller_account_id: canonical.seller_account_id.clone(), seller_pubkey: canonical.seller_pubkey.clone(), + seller_actor_source: canonical.seller_actor_source.clone(), event_kind: KIND_LISTING, dry_run: false, deduplicated: false, @@ -1947,7 +2309,11 @@ fn resolve_listing_signing_identity( config: &RuntimeConfig, canonical: &CanonicalListingDraft, ) -> Result<accounts::AccountSigningIdentity, RuntimeError> { - let signing = accounts::resolve_local_signing_identity(config)?; + let signing = accounts::resolve_local_signing_identity_for_account( + config, + canonical.seller_account_id.as_str(), + ) + .map_err(|error| listing_bound_signing_error(error, canonical))?; let account_pubkey = signing .account .record @@ -1955,15 +2321,66 @@ fn resolve_listing_signing_identity( .public_key_hex .as_str(); if !account_pubkey.eq_ignore_ascii_case(canonical.seller_pubkey.as_str()) { - return Err(accounts::AccountRuntimeFailure::mismatch(format!( - "account mismatch: resolved account pubkey `{account_pubkey}` cannot sign listing seller_pubkey `{}`", - canonical.seller_pubkey - )) + return Err(accounts::AccountRuntimeFailure::mismatch_with_detail( + format!( + "account mismatch: listing-bound seller account `{}` pubkey `{account_pubkey}` cannot sign listing seller_pubkey `{}`", + canonical.seller_account_id, canonical.seller_pubkey + ), + json!({ + "seller_actor_source": canonical.seller_actor_source, + "listing_seller_account_id": canonical.seller_account_id, + "listing_seller_pubkey": canonical.seller_pubkey, + "account_pubkey": account_pubkey, + "actions": [ + "radroots account import <path>", + "radroots account attach-secret <account-id> <path>", + ], + }), + ) .into()); } Ok(signing) } +fn listing_bound_signing_error( + error: RuntimeError, + canonical: &CanonicalListingDraft, +) -> RuntimeError { + match error { + RuntimeError::Account(accounts::AccountRuntimeFailure::Unresolved(issue)) => { + accounts::AccountRuntimeFailure::unresolved_with_detail( + issue.message().to_owned(), + json!({ + "seller_actor_source": canonical.seller_actor_source, + "listing_seller_account_id": canonical.seller_account_id, + "listing_seller_pubkey": canonical.seller_pubkey, + "actions": [ + "radroots account import <path>", + format!("radroots listing rebind <file> {}", canonical.seller_account_id), + ], + }), + ) + .into() + } + RuntimeError::Account(accounts::AccountRuntimeFailure::WatchOnly(issue)) => { + accounts::AccountRuntimeFailure::watch_only_with_detail( + &canonical.seller_account_id, + json!({ + "seller_actor_source": canonical.seller_actor_source, + "listing_seller_account_id": canonical.seller_account_id, + "listing_seller_pubkey": canonical.seller_pubkey, + "reason": issue.message(), + "actions": [ + format!("radroots account attach-secret {} <path>", canonical.seller_account_id), + ], + }), + ) + .into() + } + other => other, + } +} + fn binding_error_view( config: &RuntimeConfig, args: &ListingMutationArgs, @@ -1984,7 +2401,9 @@ fn binding_error_view( file: args.file.display().to_string(), listing_id: canonical.listing_id.clone(), listing_addr, + seller_account_id: canonical.seller_account_id.clone(), seller_pubkey: canonical.seller_pubkey.clone(), + seller_actor_source: canonical.seller_actor_source.clone(), event_kind: KIND_LISTING, dry_run: false, deduplicated: false, @@ -2045,7 +2464,9 @@ fn published_mutation_view( file: args.file.display().to_string(), listing_id: canonical.listing_id.clone(), listing_addr: listing_addr.clone(), + seller_account_id: canonical.seller_account_id.clone(), seller_pubkey: canonical.seller_pubkey.clone(), + seller_actor_source: canonical.seller_actor_source.clone(), event_kind: KIND_LISTING, dry_run: false, deduplicated: false, @@ -2178,7 +2599,7 @@ fn issue_from_trade_validation( match error { RadrootsTradeListingValidationError::InvalidSeller => issue_for_field( contents, - "listing.seller_pubkey", + "seller_actor.pubkey", "listing author does not match the farm pubkey", ), RadrootsTradeListingValidationError::MissingTitle => { @@ -2222,7 +2643,20 @@ fn issue_from_trade_validation( } fn authoring_defaults(config: &RuntimeConfig) -> Result<ListingAuthoringDefaults, RuntimeError> { - let selected_account = accounts::resolve_account(config)?; + let account_resolution = accounts::resolve_account_resolution(config)?; + let Some(selected_account) = account_resolution.resolved_account.clone() else { + return Err(accounts::AccountRuntimeFailure::unresolved_with_detail( + "no resolved account is available for listing seller actor", + json!({ + "seller_actor_source": LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT, + "actions": [ + "radroots account create", + "radroots account import <path>", + ], + }), + ) + .into()); + }; let mut defaults = ListingAuthoringDefaults { farm_config_present: false, farm_defaults_ready: false, @@ -2232,12 +2666,13 @@ fn authoring_defaults(config: &RuntimeConfig) -> Result<ListingAuthoringDefaults .to_owned(), ), farm_name: None, - selected_account_id: selected_account - .as_ref() - .map(|account| account.record.account_id.to_string()), - selected_account_pubkey: selected_account - .as_ref() - .map(|account| account.record.public_identity.public_key_hex.clone()), + seller_account_id: selected_account.record.account_id.to_string(), + seller_pubkey: selected_account + .record + .public_identity + .public_key_hex + .clone(), + seller_actor_source: LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(), selected_farm_d_tag: None, delivery_method: None, location: None, @@ -2273,8 +2708,9 @@ fn authoring_defaults(config: &RuntimeConfig) -> Result<ListingAuthoringDefaults .and_then(non_empty) .or_else(|| non_empty(resolved.document.profile.name.clone())) .or_else(|| non_empty(resolved.document.farm.name.clone())); - defaults.selected_account_id = Some(resolved.document.selection.account.clone()); - defaults.selected_account_pubkey = Some(account.record.public_identity.public_key_hex.clone()); + defaults.seller_account_id = resolved.document.selection.account.clone(); + defaults.seller_pubkey = account.record.public_identity.public_key_hex.clone(); + defaults.seller_actor_source = LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG.to_owned(); defaults.selected_farm_d_tag = Some(resolved.document.selection.farm_d_tag.clone()); let draft_missing = farm_config::missing_fields(&resolved.document); defaults.farm_defaults_ready = !draft_missing.iter().any(|field| { @@ -2300,18 +2736,6 @@ fn authoring_defaults(config: &RuntimeConfig) -> Result<ListingAuthoringDefaults Ok(defaults) } -fn resolve_selected_farm_d_tag( - config: &RuntimeConfig, - seller_pubkey: &str, -) -> Result<Option<String>, RuntimeError> { - if !config.local.replica_db_path.exists() { - return Ok(None); - } - let db = ReplicaSql::new(SqliteExecutor::open(&config.local.replica_db_path)?); - db.farm_unique_d_tag_by_pubkey(seller_pubkey) - .map_err(RuntimeError::from) -} - fn draft_location_from_model(location: &RadrootsListingLocation) -> ListingDraftLocation { ListingDraftLocation { primary: location.primary.clone(), @@ -2416,7 +2840,9 @@ fn line_for_field(contents: &str, field: &str) -> Option<usize> { "kind" => &["kind ="], "listing.d_tag" => &["d_tag ="], "listing.farm_d_tag" => &["farm_d_tag ="], - "listing.seller_pubkey" => &["seller_pubkey ="], + "seller_actor.account_id" => &["[seller_actor]", "account_id ="], + "seller_actor.pubkey" => &["[seller_actor]", "pubkey ="], + "seller_actor.source" => &["[seller_actor]", "source ="], "product.key" => &["key ="], "product.title" => &["title ="], "product.category" => &["category ="], @@ -2669,7 +3095,11 @@ mod tests { listing: super::ListingDraftMeta { d_tag: "AAAAAAAAAAAAAAAAAAAAAg".to_owned(), farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_owned(), - seller_pubkey: "a".repeat(64), + }, + seller_actor: super::ListingDraftSellerActor { + account_id: "acct_seller".to_owned(), + pubkey: "a".repeat(64), + source: super::LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(), }, product: super::ListingDraftProduct { key: "sku".to_owned(), @@ -2720,7 +3150,11 @@ mod tests { listing: super::ListingDraftMeta { d_tag: "AAAAAAAAAAAAAAAAAAAAAg".to_owned(), farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_owned(), - seller_pubkey: seller_pubkey.clone(), + }, + seller_actor: super::ListingDraftSellerActor { + account_id: "acct_seller".to_owned(), + pubkey: seller_pubkey.clone(), + source: super::LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(), }, product: super::ListingDraftProduct { key: "sku".to_owned(), @@ -2769,9 +3203,6 @@ mod tests { }; let contents = toml::to_string_pretty(&document).expect("render draft"); let context = super::ListingValidationContext { - selected_account_id: Some("acct_seller".to_owned()), - selected_account_pubkey: Some(seller_pubkey), - selected_farm_d_tag: Some("AAAAAAAAAAAAAAAAAAAAAw".to_owned()), farm_setup_action: "radroots farm create".to_owned(), }; diff --git a/src/runtime_args.rs b/src/runtime_args.rs @@ -168,6 +168,13 @@ pub struct ListingFileArgs { } #[derive(Debug, Clone)] +pub struct ListingRebindArgs { + pub file: PathBuf, + pub selector: String, + pub farm_d_tag: Option<String>, +} + +#[derive(Debug, Clone)] pub struct ListingMutationArgs { pub file: PathBuf, pub idempotency_key: Option<String>, diff --git a/src/target_cli.rs b/src/target_cli.rs @@ -185,6 +185,7 @@ impl TargetCommand { ListingCommand::List => "listing.list", ListingCommand::Update(_) => "listing.update", ListingCommand::Validate(_) => "listing.validate", + ListingCommand::Rebind(_) => "listing.rebind", ListingCommand::Publish(_) => "listing.publish", ListingCommand::Archive(_) => "listing.archive", }, @@ -588,6 +589,7 @@ pub enum ListingCommand { List, Update(FileArgs), Validate(FileArgs), + Rebind(ListingRebindArgs), Publish(FileArgs), Archive(FileArgs), } @@ -642,6 +644,14 @@ pub struct FileArgs { } #[derive(Debug, Clone, Args)] +pub struct ListingRebindArgs { + pub file: Option<PathBuf>, + pub selector: Option<String>, + #[arg(long = "farm-d-tag")] + pub farm_d_tag: Option<String>, +} + +#[derive(Debug, Clone, Args)] pub struct LookupArgs { pub key: Option<String>, } @@ -1047,7 +1057,7 @@ mod tests { use clap::{CommandFactory, Parser}; use super::{ - AccountCommand, FarmCommand, OrderCommand, OrderFulfillmentCommand, + AccountCommand, FarmCommand, ListingCommand, OrderCommand, OrderFulfillmentCommand, OrderFulfillmentStateArg, OrderPaymentCommand, OrderReceiptCommand, OrderRevisionCommand, OrderSettlementCommand, TargetCliArgs, TargetOutputFormat, }; @@ -1180,6 +1190,34 @@ mod tests { } #[test] + fn target_parser_accepts_listing_rebind_inputs() { + let parsed = TargetCliArgs::try_parse_from([ + "radroots", + "listing", + "rebind", + "listing.toml", + "acct_test", + "--farm-d-tag", + "AAAAAAAAAAAAAAAAAAAAAw", + ]) + .expect("target args parse"); + + assert_eq!(parsed.command.operation_id(), "listing.rebind"); + let crate::target_cli::TargetCommand::Listing(listing) = parsed.command else { + panic!("expected listing command") + }; + let ListingCommand::Rebind(args) = listing.command else { + panic!("expected listing rebind command") + }; + assert_eq!( + args.file.as_ref().map(|path| path.as_os_str()), + Some(std::ffi::OsStr::new("listing.toml")) + ); + assert_eq!(args.selector.as_deref(), Some("acct_test")); + assert_eq!(args.farm_d_tag.as_deref(), Some("AAAAAAAAAAAAAAAAAAAAAw")); + } + + #[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 @@ -791,7 +791,21 @@ fn myc_mode_allows_read_inspection_commands() { #[test] fn local_listing_publish_fails_without_local_account_authority() { let sandbox = RadrootsCliSandbox::new(); + let account = sandbox.json_success(&["--format", "json", "account", "create"]); + let account_id = account["result"]["account"]["id"] + .as_str() + .expect("account id"); let listing_file = create_listing_draft(&sandbox, "local-no-account"); + make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw"); + sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "remove", + account_id, + ]); let (output, value) = sandbox.json_output(&[ "--format", @@ -813,14 +827,28 @@ fn local_listing_publish_fails_without_local_account_authority() { assert_eq!(value["errors"][0]["detail"]["class"], "account"); assert_contains( &value["errors"][0]["message"], - "no resolved account pubkey is available", + "listing-bound seller account", ); } #[test] fn local_listing_publish_dry_run_validates_local_account_authority() { let sandbox = RadrootsCliSandbox::new(); + let account = sandbox.json_success(&["--format", "json", "account", "create"]); + let account_id = account["result"]["account"]["id"] + .as_str() + .expect("account id"); let listing_file = create_listing_draft(&sandbox, "local-dry-run-no-account"); + make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw"); + sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "remove", + account_id, + ]); let (output, value) = sandbox.json_output(&[ "--format", @@ -842,7 +870,21 @@ fn local_listing_publish_dry_run_validates_local_account_authority() { #[test] fn local_listing_update_dry_run_validates_local_account_authority() { let sandbox = RadrootsCliSandbox::new(); + let account = sandbox.json_success(&["--format", "json", "account", "create"]); + let account_id = account["result"]["account"]["id"] + .as_str() + .expect("account id"); let listing_file = create_listing_draft(&sandbox, "local-update-dry-run-no-account"); + make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw"); + sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "remove", + account_id, + ]); let (output, value) = sandbox.json_output(&[ "--format", @@ -1013,7 +1055,7 @@ fn local_listing_publish_fails_when_selected_account_does_not_match_seller() { assert_eq!(value["errors"][0]["detail"]["class"], "account"); assert_contains( &value["errors"][0]["message"], - "cannot sign listing seller_pubkey", + "listing draft is bound to seller account", ); assert_no_removed_command_reference(&value, &["listing", "publish", "account mismatch"]); } diff --git a/tests/support/mod.rs b/tests/support/mod.rs @@ -308,6 +308,10 @@ pub fn replace_latest_listing_event_id( } pub fn create_listing_draft(sandbox: &RadrootsCliSandbox, key: &str) -> PathBuf { + let accounts = sandbox.json_success(&["--format", "json", "account", "list"]); + if accounts["result"]["count"].as_u64().unwrap_or_default() == 0 { + sandbox.json_success(&["--format", "json", "account", "create"]); + } let listing_file = sandbox.root().join(format!("{key}.toml")); let listing_file_arg = listing_file.to_string_lossy(); let value = sandbox.json_success(&[ @@ -358,11 +362,15 @@ pub fn identity_secret(seed: u8) -> RadrootsIdentity { pub fn make_listing_publishable(path: &Path, farm_d_tag: &str) { let raw = fs::read_to_string(path).expect("listing draft"); let mut seller_pubkey_present = false; + let mut in_seller_actor = false; let patched = raw .lines() .map(|line| { let trimmed = line.trim_start(); - if trimmed.starts_with("seller_pubkey =") { + if trimmed.starts_with('[') { + in_seller_actor = trimmed == "[seller_actor]"; + } + if in_seller_actor && trimmed.starts_with("pubkey =") { seller_pubkey_present = !trimmed.ends_with("\"\""); line.to_owned() } else if trimmed.starts_with("farm_d_tag =") { @@ -384,13 +392,17 @@ pub fn make_listing_publishable(path: &Path, farm_d_tag: &str) { pub fn make_listing_publishable_with_seller(path: &Path, farm_d_tag: &str, seller_pubkey: &str) { let raw = fs::read_to_string(path).expect("listing draft"); let mut seller_pubkey_field_present = false; + let mut in_seller_actor = false; let patched = raw .lines() .map(|line| { let trimmed = line.trim_start(); - if trimmed.starts_with("seller_pubkey =") { + if trimmed.starts_with('[') { + in_seller_actor = trimmed == "[seller_actor]"; + } + if in_seller_actor && trimmed.starts_with("pubkey =") { seller_pubkey_field_present = true; - format!("{}seller_pubkey = \"{}\"", line_indent(line), seller_pubkey) + format!("{}pubkey = \"{}\"", line_indent(line), seller_pubkey) } else if trimmed.starts_with("farm_d_tag =") { format!("{}farm_d_tag = \"{}\"", line_indent(line), farm_d_tag) } else if trimmed.starts_with("method =") { diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -59,7 +59,7 @@ impl OneShotJsonRpcServer { "signer_session_id": "session_test", "event_kind": 30402, "event_id": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "event_addr": "30402:daemon_test:radrootsd-router", + "event_addr": null, "relay_count": 2, "acknowledged_relay_count": 1 } @@ -989,14 +989,10 @@ signer_session_ref = "session_test" assert_eq!(value["result"]["event_id"], "e".repeat(64)); assert_eq!( value["result"]["event_addr"], - "30402:daemon_test:radrootsd-router" + value["result"]["listing_addr"] ); - assert_eq!( - value["result"]["listing_addr"], - "30402:daemon_test:radrootsd-router" - ); - assert_eq!(value["result"]["listing_id"], "radrootsd-router"); - assert_eq!(value["result"]["seller_pubkey"], "daemon_test"); + assert!(value["result"]["listing_id"].is_string()); + assert!(value["result"]["seller_pubkey"].is_string()); assert_eq!(value["result"]["signer_mode"], "nip46"); assert_eq!(value["result"]["signer_session_id"], "session_test"); assert_eq!( @@ -1205,7 +1201,7 @@ fn radrootsd_farm_publish_missing_signer_binding_points_to_capability_binding() } #[test] -fn radrootsd_listing_writes_dry_run_use_draft_identity_without_local_account() { +fn radrootsd_listing_writes_dry_run_reject_missing_invocation_account() { for operation in ["publish", "update", "archive"] { let sandbox = RadrootsCliSandbox::new(); let seller = identity_public(42); @@ -1249,25 +1245,16 @@ signer_session_ref = "session_test" .expect("run radrootsd dry-run listing write"); let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); - assert!(output.status.success()); + assert!(!output.status.success()); assert_eq!(value["operation_id"], format!("listing.{operation}")); - assert_eq!(value["result"]["state"], "dry_run"); - assert_eq!( - value["result"]["source"], - "radrootsd publish transport · signer session" - ); - assert_eq!(value["result"]["seller_pubkey"], seller.public_key_hex); - assert_eq!( - value["result"]["requested_signer_session_id"], - "session_test" - ); - assert_eq!(value["result"]["signer_mode"], "nip46"); - assert_eq!(value["errors"].as_array().expect("errors").len(), 0); + assert_eq!(value["result"], Value::Null); + assert_eq!(value["errors"][0]["code"], "account_unresolved"); + assert_eq!(value["errors"][0]["detail"]["class"], "account"); } } #[test] -fn radrootsd_listing_writes_use_draft_identity_without_local_account() { +fn radrootsd_listing_writes_reject_missing_invocation_account() { for operation in ["publish", "update", "archive"] { let sandbox = RadrootsCliSandbox::new(); let seller = identity_public(43); @@ -1292,11 +1279,8 @@ target = "http://myc.invalid" signer_session_ref = "session_test" "#, ); - let server = OneShotJsonRpcServer::listing_publish(); - let mut command = sandbox.command(); command - .env("RADROOTS_RPC_URL", &server.endpoint) .env("RADROOTS_RPC_BEARER_TOKEN", "bridge_test") .args([ "--format", @@ -1311,23 +1295,12 @@ signer_session_ref = "session_test" ]); let output = command.output().expect("run radrootsd listing write"); let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); - let request = server.take_request(); - assert!(output.status.success()); + assert!(!output.status.success()); assert_eq!(value["operation_id"], format!("listing.{operation}")); - assert_eq!( - value["result"]["source"], - "radrootsd publish transport · signer session" - ); - assert_eq!( - value["result"]["listing_addr"], - "30402:daemon_test:radrootsd-router" - ); - assert_eq!(value["result"]["listing_id"], "radrootsd-router"); - assert_eq!(value["result"]["seller_pubkey"], "daemon_test"); - assert_eq!(request.body["method"], "bridge.listing.publish"); - assert_eq!(request.body["params"]["signer_session_id"], "session_test"); - assert_eq!(value["errors"].as_array().expect("errors").len(), 0); + assert_eq!(value["result"], Value::Null); + assert_eq!(value["errors"][0]["code"], "account_unresolved"); + assert_eq!(value["errors"][0]["detail"]["class"], "account"); } } @@ -1352,14 +1325,9 @@ fn radrootsd_listing_publish_bridge_errors_are_classified() { ), ] { let sandbox = RadrootsCliSandbox::new(); - let seller = identity_public(44); let listing_file = create_listing_draft(&sandbox, format!("radrootsd-bridge-error-{class}").as_str()); - make_listing_publishable_with_seller( - &listing_file, - "AAAAAAAAAAAAAAAAAAAAAw", - seller.public_key_hex.as_str(), - ); + make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw"); sandbox.write_app_config( r#"[publish] mode = "radrootsd" @@ -2054,6 +2022,103 @@ fn listing_list_reports_default_local_drafts() { } #[test] +fn listing_rebind_updates_seller_actor_with_approval() { + 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 listing_file = create_listing_draft(&sandbox, "rebind-listing"); + make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw"); + let initial_validation = sandbox.json_success(&[ + "--format", + "json", + "listing", + "validate", + listing_file.to_string_lossy().as_ref(), + ]); + let first_pubkey = initial_validation["result"]["seller_pubkey"] + .as_str() + .expect("first pubkey"); + let before = fs::read_to_string(&listing_file).expect("listing before"); + let second = sandbox.json_success(&["--format", "json", "account", "create"]); + let second_account_id = second["result"]["account"]["id"] + .as_str() + .expect("second account id"); + + let dry_run = sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "listing", + "rebind", + listing_file.to_string_lossy().as_ref(), + second_account_id, + "--farm-d-tag", + "AAAAAAAAAAAAAAAAAAAAAw", + ]); + assert_eq!(dry_run["operation_id"], "listing.rebind"); + assert_eq!(dry_run["result"]["state"], "dry_run"); + assert_eq!( + dry_run["result"]["from_seller_account_id"], + first_account_id + ); + assert_eq!(dry_run["result"]["from_seller_pubkey"], first_pubkey); + assert_eq!(dry_run["result"]["to_seller_account_id"], second_account_id); + let second_pubkey = dry_run["result"]["to_seller_pubkey"] + .as_str() + .expect("second pubkey"); + assert_eq!(dry_run["result"]["seller_pubkey_changed"], true); + assert_eq!( + fs::read_to_string(&listing_file).expect("listing after dry-run"), + before + ); + + let unapproved = sandbox.json_output(&[ + "--format", + "json", + "listing", + "rebind", + listing_file.to_string_lossy().as_ref(), + second_account_id, + "--farm-d-tag", + "AAAAAAAAAAAAAAAAAAAAAw", + ]); + assert!(!unapproved.0.status.success()); + assert_eq!(unapproved.1["errors"][0]["code"], "approval_required"); + + let rebound = sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "listing", + "rebind", + listing_file.to_string_lossy().as_ref(), + second_account_id, + "--farm-d-tag", + "AAAAAAAAAAAAAAAAAAAAAw", + ]); + assert_eq!(rebound["operation_id"], "listing.rebind"); + assert_eq!(rebound["result"]["state"], "rebound"); + let after = fs::read_to_string(&listing_file).expect("listing after rebind"); + assert!(after.contains("[seller_actor]")); + assert!(after.contains(second_account_id)); + assert!(after.contains("source = \"listing_rebind\"")); + + let validation = sandbox.json_success(&[ + "--format", + "json", + "listing", + "validate", + listing_file.to_string_lossy().as_ref(), + ]); + assert_eq!(validation["result"]["valid"], true); + assert_eq!(validation["result"]["seller_account_id"], second_account_id); + assert_eq!(validation["result"]["seller_pubkey"], second_pubkey); +} + +#[test] fn account_id_global_populates_envelope_actor() { let output = radroots() .args([