cli

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

commit 5512a8d74905fa805539457e67d5cd72b54d6810
parent 42903a0ae254a778a15240da98babcdad47a4fd9
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 20:44:32 +0000

cli: fail seller relay writes closed

- remove daemon bridge publish paths from farm and listing mutations
- keep seller dry-runs validating local draft and account authority
- return operation_unavailable until direct relay publishing exists
- cover publish and archive outputs without daemon job references

Diffstat:
Msrc/operation_farm.rs | 1-
Msrc/operation_listing.rs | 1-
Msrc/runtime/farm.rs | 485+++++++++----------------------------------------------------------------------
Msrc/runtime/listing.rs | 375+++++++------------------------------------------------------------------------
Msrc/runtime_args.rs | 2--
Mtests/signer_runtime_modes.rs | 93+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mtests/support/mod.rs | 10++++++++++
Mtests/target_cli.rs | 48++++++++++++++++++++++++++++++++++++++----------
8 files changed, 182 insertions(+), 833 deletions(-)

diff --git a/src/operation_farm.rs b/src/operation_farm.rs @@ -136,7 +136,6 @@ impl OperationService<FarmPublishRequest> for FarmOperationService<'_> { .clone() .or_else(|| string_input(&request, "idempotency_key")), signer_session_id: string_input(&request, "signer_session_id"), - print_job: bool_input(&request, "print_job").unwrap_or(false), print_event: bool_input(&request, "print_event").unwrap_or(false), }; if request.context.requires_approval_token() { diff --git a/src/operation_listing.rs b/src/operation_listing.rs @@ -198,7 +198,6 @@ where .clone() .or_else(|| string_input(request, "idempotency_key")), signer_session_id: string_input(request, "signer_session_id"), - print_job: bool_input(request, "print_job").unwrap_or(false), print_event: bool_input(request, "print_event").unwrap_or(false), }) } diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -11,13 +11,12 @@ use radroots_events_codec::profile::encode::to_wire_parts_with_profile_type; use crate::domain::runtime::{ FarmConfigDocumentView, FarmConfigSummaryView, FarmGetView, FarmListingDefaultsView, - FarmPublicationView, FarmPublishComponentView, FarmPublishEventView, FarmPublishJobView, - FarmPublishView, FarmSelectionView, FarmSetView, FarmSetupView, FarmStatusView, + FarmPublicationView, FarmPublishComponentView, FarmPublishEventView, FarmPublishView, + FarmSelectionView, FarmSetView, FarmSetupView, FarmStatusView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts::{self, AccountRecordView}; use crate::runtime::config::RuntimeConfig; -use crate::runtime::daemon::{self, BridgeEventPublishResult, DaemonRpcError}; use crate::runtime::farm_config::{ self, FarmConfigDocument, FarmConfigScope, FarmConfigSelection, FarmListingDefaults, FarmMissingField, FarmPublicationStatus, ResolvedFarmConfig, SUPPORTED_FARM_CONFIG_VERSION, @@ -28,6 +27,9 @@ use crate::runtime_args::{ }; const FARM_CONFIG_SOURCE: &str = "farm config · local first"; +const FARM_WRITE_SOURCE: &str = "direct Nostr relay publish · pending implementation"; +const DIRECT_RELAY_UNAVAILABLE_REASON: &str = + "direct Nostr relay publishing is not implemented for farm publish"; static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -345,21 +347,18 @@ pub fn publish( let profile_idempotency_key = component_idempotency_key(args, "profile")?; let farm_idempotency_key = component_idempotency_key(args, "farm")?; - let signer_authority = match resolve_farm_write_authority(config, account_pubkey.as_str()) { - Ok(authority) => authority, - Err(error) => { - return Ok(binding_error_publish_view( - config, - args, - &resolved, - &account_pubkey, - previews, - profile_idempotency_key, - farm_idempotency_key, - error, - )); - } - }; + if let Err(error) = resolve_farm_write_authority(config, account_pubkey.as_str()) { + return Ok(binding_error_publish_view( + config, + args, + &resolved, + &account_pubkey, + previews, + profile_idempotency_key, + farm_idempotency_key, + error, + )); + } if config.output.dry_run { return Ok(base_publish_view( @@ -369,163 +368,35 @@ pub fn publish( &resolved, &account_pubkey, preview_component( - "bridge.profile.publish", + "relay.profile.publish", KIND_PROFILE, profile_idempotency_key, args, Some(previews.profile), ), preview_component( - "bridge.farm.publish", + "relay.farm.publish", KIND_FARM, farm_idempotency_key, args, Some(previews.farm), ), - Some("dry run requested; daemon farm publish skipped".to_owned()), + Some("dry run requested; relay publish skipped".to_owned()), vec![format!( "radroots farm publish --scope {}", resolved.scope.as_str() )], )); } - let profile_signer_session_id = match daemon::resolve_signer_session_id( - config, - "farm profile", - account_pubkey.as_str(), - KIND_PROFILE, - args.signer_session_id.as_deref(), - signer_authority.as_ref(), - ) { - Ok(session_id) => session_id, - Err(error) => { - return Ok(daemon_error_publish_view( - config, - args, - &resolved, - &account_pubkey, - previews, - profile_idempotency_key, - farm_idempotency_key, - error, - FarmPublishFailureStage::Profile, - )); - } - }; - let farm_signer_session_id = match daemon::resolve_signer_session_id( - config, - "farm", - account_pubkey.as_str(), - KIND_FARM, - Some(profile_signer_session_id.as_str()), - signer_authority.as_ref(), - ) { - Ok(session_id) => session_id, - Err(error) => { - return Ok(daemon_error_publish_view( - config, - args, - &resolved, - &account_pubkey, - previews, - profile_idempotency_key, - farm_idempotency_key, - error, - FarmPublishFailureStage::Farm, - )); - } - }; - - let profile_result = match daemon::bridge_profile_publish( - config, - &resolved.document.profile, - Some(RadrootsProfileType::Farm), - profile_idempotency_key.as_deref(), - Some(profile_signer_session_id.as_str()), - signer_authority.as_ref(), - ) { - Ok(result) => result_component( - "bridge.profile.publish", - KIND_PROFILE, - result, - args, - Some(previews.profile.clone()), - ), - Err(error) => { - return Ok(daemon_error_publish_view( - config, - args, - &resolved, - &account_pubkey, - previews, - profile_idempotency_key, - farm_idempotency_key, - error, - FarmPublishFailureStage::Profile, - )); - } - }; - - if component_failed(&profile_result) { - return Ok(persist_publication_and_view( - config, - args, - resolved, - &account_pubkey, - profile_result, - preview_component( - "bridge.farm.publish", - KIND_FARM, - farm_idempotency_key, - args, - Some(previews.farm), - ), - )?); - } - - let farm_result = match daemon::bridge_farm_publish( - config, - &resolved.document.farm, - farm_idempotency_key.as_deref(), - Some(farm_signer_session_id.as_str()), - signer_authority.as_ref(), - ) { - Ok(result) => result_component( - "bridge.farm.publish", - KIND_FARM, - result, - args, - Some(previews.farm), - ), - Err(error) => { - let profile_for_error = profile_result.clone(); - let farm_for_error = daemon_error_component( - "bridge.farm.publish", - KIND_FARM, - args, - farm_idempotency_key, - error, - Some(previews.farm), - ); - return Ok(persist_publication_and_view( - config, - args, - resolved, - &account_pubkey, - profile_for_error, - farm_for_error, - )?); - } - }; - - persist_publication_and_view( + Ok(direct_relay_unavailable_publish_view( config, args, - resolved, + &resolved, &account_pubkey, - profile_result, - farm_result, - ) + previews, + profile_idempotency_key, + farm_idempotency_key, + )) } #[derive(Debug, Clone)] @@ -534,12 +405,6 @@ struct FarmPublishPreviews { farm: FarmPublishEventView, } -#[derive(Debug, Clone, Copy)] -enum FarmPublishFailureStage { - Profile, - Farm, -} - fn missing_publish_view( scope: FarmConfigScope, path: String, @@ -555,7 +420,7 @@ fn missing_publish_view( ) -> FarmPublishView { FarmPublishView { state: "unconfigured".to_owned(), - source: daemon::bridge_source().to_owned(), + source: FARM_WRITE_SOURCE.to_owned(), scope: scope.as_str().to_owned(), path, config_present, @@ -564,8 +429,8 @@ fn missing_publish_view( selected_account_pubkey, farm_d_tag, requested_signer_session_id: args.signer_session_id.clone(), - profile: not_submitted_component("bridge.profile.publish", KIND_PROFILE, args, None, None), - farm: not_submitted_component("bridge.farm.publish", KIND_FARM, args, None, None), + profile: not_submitted_component("relay.profile.publish", KIND_PROFILE, args, None, None), + farm: not_submitted_component("relay.farm.publish", KIND_FARM, args, None, None), missing, reason: Some(reason), actions, @@ -611,7 +476,7 @@ fn base_publish_view( ) -> FarmPublishView { FarmPublishView { state: state.to_owned(), - source: daemon::bridge_source().to_owned(), + source: FARM_WRITE_SOURCE.to_owned(), scope: resolved.scope.as_str().to_owned(), path: resolved.path.display().to_string(), config_present: true, @@ -698,15 +563,7 @@ fn preview_component( event_addr: event.as_ref().and_then(|event| event.event_addr.clone()), idempotency_key: idempotency_key.clone(), reason: Some("not submitted".to_owned()), - job: args.print_job.then(|| FarmPublishJobView { - rpc_method: rpc_method.to_owned(), - state: "not_submitted".to_owned(), - job_id: None, - idempotency_key, - requested_signer_session_id: args.signer_session_id.clone(), - signer_mode: None, - signer_session_id: None, - }), + job: None, event: args.print_event.then_some(event).flatten(), } } @@ -721,111 +578,6 @@ fn not_submitted_component( preview_component(rpc_method, event_kind, idempotency_key, args, event) } -fn result_component( - rpc_method: &str, - fallback_event_kind: u32, - result: BridgeEventPublishResult, - args: &FarmPublishArgs, - preview: Option<FarmPublishEventView>, -) -> FarmPublishComponentView { - let state = if result.status == "failed" { - "failed".to_owned() - } else if result.deduplicated { - "deduplicated".to_owned() - } else { - result.status.clone() - }; - let event_kind = result.event_kind.unwrap_or(fallback_event_kind); - let event_addr = result - .event_addr - .clone() - .or_else(|| preview.as_ref().and_then(|event| event.event_addr.clone())); - let event = args.print_event.then(|| FarmPublishEventView { - event_id: result.event_id.clone(), - event_addr: event_addr.clone(), - ..preview.unwrap_or_else(|| FarmPublishEventView { - kind: event_kind, - author: String::new(), - content: String::new(), - tags: Vec::new(), - event_id: None, - event_addr: None, - }) - }); - FarmPublishComponentView { - state: state.clone(), - rpc_method: rpc_method.to_owned(), - event_kind, - deduplicated: result.deduplicated, - job_id: Some(result.job_id.clone()), - job_status: Some(result.status.clone()), - signer_mode: Some(result.signer_mode.clone()), - signer_session_id: result.signer_session_id.clone(), - event_id: result.event_id.clone(), - event_addr, - idempotency_key: result.idempotency_key.clone(), - reason: (result.status == "failed") - .then(|| "daemon publish job failed before relay delivery completed".to_owned()), - job: args.print_job.then(|| FarmPublishJobView { - rpc_method: rpc_method.to_owned(), - state, - job_id: Some(result.job_id), - idempotency_key: result.idempotency_key, - requested_signer_session_id: args.signer_session_id.clone(), - signer_mode: Some(result.signer_mode), - signer_session_id: result.signer_session_id, - }), - event, - } -} - -fn daemon_error_component( - rpc_method: &str, - event_kind: u32, - args: &FarmPublishArgs, - idempotency_key: Option<String>, - error: DaemonRpcError, - preview: Option<FarmPublishEventView>, -) -> FarmPublishComponentView { - let (state, reason) = daemon_error_state_reason(error); - FarmPublishComponentView { - state: state.clone(), - rpc_method: rpc_method.to_owned(), - event_kind, - deduplicated: false, - job_id: None, - job_status: None, - signer_mode: None, - signer_session_id: None, - event_id: None, - event_addr: preview.as_ref().and_then(|event| event.event_addr.clone()), - idempotency_key: idempotency_key.clone(), - reason: Some(reason), - job: args.print_job.then(|| FarmPublishJobView { - rpc_method: rpc_method.to_owned(), - state, - job_id: None, - idempotency_key, - requested_signer_session_id: args.signer_session_id.clone(), - signer_mode: None, - signer_session_id: None, - }), - event: args.print_event.then_some(preview).flatten(), - } -} - -fn daemon_error_state_reason(error: DaemonRpcError) -> (String, String) { - match error { - DaemonRpcError::Unconfigured(reason) - | DaemonRpcError::Unauthorized(reason) - | DaemonRpcError::MethodUnavailable(reason) => ("unconfigured".to_owned(), reason), - DaemonRpcError::External(reason) => ("unavailable".to_owned(), reason), - DaemonRpcError::InvalidResponse(reason) - | DaemonRpcError::Remote(reason) - | DaemonRpcError::UnknownJob(reason) => ("error".to_owned(), reason), - } -} - fn binding_error_publish_view( config: &RuntimeConfig, args: &FarmPublishArgs, @@ -853,7 +605,7 @@ fn binding_error_publish_view( state: state.clone(), reason: Some(reason.clone()), ..preview_component( - "bridge.profile.publish", + "relay.profile.publish", KIND_PROFILE, profile_idempotency_key, args, @@ -864,7 +616,7 @@ fn binding_error_publish_view( state: state.clone(), reason: Some(reason.clone()), ..preview_component( - "bridge.farm.publish", + "relay.farm.publish", KIND_FARM, farm_idempotency_key, args, @@ -876,7 +628,7 @@ fn binding_error_publish_view( ) } -fn daemon_error_publish_view( +fn direct_relay_unavailable_publish_view( config: &RuntimeConfig, args: &FarmPublishArgs, resolved: &ResolvedFarmConfig, @@ -884,170 +636,40 @@ fn daemon_error_publish_view( previews: FarmPublishPreviews, profile_idempotency_key: Option<String>, farm_idempotency_key: Option<String>, - error: DaemonRpcError, - stage: FarmPublishFailureStage, ) -> FarmPublishView { - let (state, reason) = daemon_error_state_reason(error); - let profile = match stage { - FarmPublishFailureStage::Profile => FarmPublishComponentView { - state: state.clone(), - reason: Some(reason.clone()), + base_publish_view( + "unavailable", + config, + args, + resolved, + account_pubkey, + FarmPublishComponentView { + state: "unavailable".to_owned(), + reason: Some(DIRECT_RELAY_UNAVAILABLE_REASON.to_owned()), ..preview_component( - "bridge.profile.publish", + "relay.profile.publish", KIND_PROFILE, profile_idempotency_key, args, Some(previews.profile), ) }, - FarmPublishFailureStage::Farm => preview_component( - "bridge.profile.publish", - KIND_PROFILE, - profile_idempotency_key, - args, - Some(previews.profile), - ), - }; - let farm = match stage { - FarmPublishFailureStage::Profile => preview_component( - "bridge.farm.publish", - KIND_FARM, - farm_idempotency_key, - args, - Some(previews.farm), - ), - FarmPublishFailureStage::Farm => FarmPublishComponentView { - state: state.clone(), - reason: Some(reason.clone()), + FarmPublishComponentView { + state: "unavailable".to_owned(), + reason: Some(DIRECT_RELAY_UNAVAILABLE_REASON.to_owned()), ..preview_component( - "bridge.farm.publish", + "relay.farm.publish", KIND_FARM, farm_idempotency_key, args, Some(previews.farm), ) }, - }; - base_publish_view( - state.as_str(), - config, - args, - resolved, - account_pubkey, - profile, - farm, - Some(reason), - daemon_error_actions(state.as_str()), + Some(DIRECT_RELAY_UNAVAILABLE_REASON.to_owned()), + Vec::new(), ) } -fn persist_publication_and_view( - config: &RuntimeConfig, - args: &FarmPublishArgs, - mut resolved: ResolvedFarmConfig, - account_pubkey: &str, - profile: FarmPublishComponentView, - farm: FarmPublishComponentView, -) -> Result<FarmPublishView, RuntimeError> { - let now = unix_timestamp_now(); - if component_published(&profile) { - if let Some(event_id) = &profile.event_id { - resolved.document.publication.profile_event_id = Some(event_id.clone()); - } - resolved.document.publication.profile_published_at = Some(now); - } - if component_published(&farm) { - if let Some(event_id) = &farm.event_id { - resolved.document.publication.farm_event_id = Some(event_id.clone()); - } - resolved.document.publication.farm_published_at = Some(now); - } - if component_published(&profile) || component_published(&farm) { - farm_config::write(&config.paths, resolved.scope, &resolved.document)?; - } - - let state = publish_view_state(&profile, &farm); - let mut actions = Vec::new(); - if let Some(job_id) = &profile.job_id { - actions.push(format!("radroots job get {job_id}")); - } - if let Some(job_id) = &farm.job_id { - actions.push(format!("radroots job get {job_id}")); - actions.push(format!("radroots job watch {job_id}")); - } - if actions.is_empty() { - actions.push("radroots runtime status get".to_owned()); - } - let reason = (state == "partial" || state == "unavailable" || state == "error") - .then(|| "farm publish did not complete for both profile and farm record".to_owned()); - - Ok(base_publish_view( - state, - config, - args, - &resolved, - account_pubkey, - profile, - farm, - reason, - actions, - )) -} - -fn publish_view_state( - profile: &FarmPublishComponentView, - farm: &FarmPublishComponentView, -) -> &'static str { - if component_error(profile) || component_error(farm) { - return "error"; - } - if component_unconfigured(profile) || component_unconfigured(farm) { - return "unconfigured"; - } - if component_failed(profile) || component_failed(farm) { - return if component_published(profile) || component_published(farm) { - "partial" - } else { - "unavailable" - }; - } - if profile.state == "deduplicated" || farm.state == "deduplicated" { - return "deduplicated"; - } - "published" -} - -fn component_published(component: &FarmPublishComponentView) -> bool { - matches!(component.state.as_str(), "published" | "deduplicated") - || component - .job_status - .as_deref() - .is_some_and(|status| status == "published") -} - -fn component_failed(component: &FarmPublishComponentView) -> bool { - matches!(component.state.as_str(), "failed" | "unavailable") -} - -fn component_unconfigured(component: &FarmPublishComponentView) -> bool { - component.state == "unconfigured" -} - -fn component_error(component: &FarmPublishComponentView) -> bool { - component.state == "error" -} - -fn daemon_error_actions(state: &str) -> Vec<String> { - match state { - "unconfigured" => vec![ - "set RADROOTS_RPC_BEARER_TOKEN in .env or your shell".to_owned(), - "start radrootsd with bridge ingress enabled".to_owned(), - ], - "unavailable" => vec!["start radrootsd and verify the rpc url".to_owned()], - _ => vec!["inspect the daemon rpc response contract".to_owned()], - } -} - fn selected_account_for_draft( config: &RuntimeConfig, ) -> Result<Option<AccountRecordView>, RuntimeError> { @@ -1579,13 +1201,6 @@ fn non_empty(value: &str) -> Option<String> { } } -fn unix_timestamp_now() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} - fn generate_d_tag() -> String { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -18,7 +18,6 @@ use radroots_events::listing::{ use radroots_events::trade::RadrootsTradeListingValidationError; use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::listing::encode::to_wire_parts_with_kind; -use radroots_nostr::prelude::radroots_nostr_build_event; use radroots_replica_db::ReplicaSql; use radroots_sql_core::SqliteExecutor; use radroots_trade::listing::publish::validate_listing_for_seller; @@ -27,14 +26,12 @@ use serde::{Deserialize, Serialize}; use crate::domain::runtime::{ FindPriceView, FindQuantityView, FindResultProvenanceView, ListingGetView, ListingListView, - ListingMutationEventView, ListingMutationJobView, ListingMutationView, ListingNewView, - ListingSummaryView, ListingValidateView, ListingValidationIssueView, SyncFreshnessView, + ListingMutationEventView, ListingMutationView, ListingNewView, ListingSummaryView, + ListingValidateView, ListingValidationIssueView, SyncFreshnessView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts; use crate::runtime::config::{RuntimeConfig, SignerBackend}; -use crate::runtime::daemon; -use crate::runtime::daemon::DaemonRpcError; use crate::runtime::farm_config; use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority}; use crate::runtime::sync::freshness_from_executor; @@ -45,8 +42,9 @@ use crate::runtime_args::{ const DRAFT_KIND: &str = "listing_draft_v1"; const LISTING_SOURCE: &str = "local draft · local first"; const LISTING_READ_SOURCE: &str = "local replica · local first"; -const LISTING_WRITE_SOURCE: &str = "daemon bridge · durable write plane"; -const LISTING_LOCAL_SIGNED_SOURCE: &str = "local account signer · signed event artifact"; +const LISTING_WRITE_SOURCE: &str = "direct Nostr relay publish · pending implementation"; +const DIRECT_RELAY_UNAVAILABLE_REASON: &str = + "direct Nostr relay publishing is not implemented for listing mutations"; const LISTING_DRAFTS_DIR: &str = "listings/drafts"; static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -806,16 +804,8 @@ fn mutate( idempotency_key: args.idempotency_key.clone(), signer_session_id: None, requested_signer_session_id: args.signer_session_id.clone(), - reason: Some("dry run requested; daemon publish skipped".to_owned()), - job: args.print_job.then(|| ListingMutationJobView { - rpc_method: "bridge.listing.publish".to_owned(), - state: "not_submitted".to_owned(), - job_id: None, - idempotency_key: args.idempotency_key.clone(), - requested_signer_session_id: args.signer_session_id.clone(), - signer_mode: Some(config.signer.backend.as_str().to_owned()), - signer_session_id: None, - }), + reason: Some("dry run requested; relay publish skipped".to_owned()), + job: None, event: args.print_event.then_some(event_preview), actions: vec![format!( "radroots listing {} {}", @@ -825,22 +815,11 @@ fn mutate( }); } - if matches!(operation, ListingMutationOperation::Publish) - && matches!(config.signer.backend, SignerBackend::Local) - { - return local_signed_view( - config, - args, - operation, - &canonical, - listing_addr, - event_preview, - ); - } - - let signer_authority = + if matches!(config.signer.backend, SignerBackend::Local) { + validate_local_listing_signer(config, &canonical)?; + } else { match resolve_actor_write_authority(config, "seller", canonical.seller_pubkey.as_str()) { - Ok(authority) => authority, + Ok(_) => {} Err(error) => { return Ok(binding_error_view( config, @@ -852,109 +831,17 @@ fn mutate( error, )); } - }; - - let signer_session_id = match daemon::resolve_signer_session_id( - config, - "seller", - canonical.seller_pubkey.as_str(), - KIND_LISTING, - args.signer_session_id.as_deref(), - signer_authority.as_ref(), - ) { - Ok(session_id) => session_id, - Err(error) => { - return Ok(daemon_error_view( - config, - args, - operation, - &canonical, - listing_addr, - event_preview, - error, - )); } - }; + } - match daemon::bridge_listing_publish( + Ok(direct_relay_unavailable_view( config, - &canonical.listing, - KIND_LISTING, - args.idempotency_key.as_deref(), - Some(signer_session_id.as_str()), - signer_authority.as_ref(), - ) { - Ok(result) => { - let failed = result.status == "failed"; - let mut actions = Vec::new(); - if failed { - if let Some(job_id) = &Some(result.job_id.clone()) { - actions.push(format!("radroots job get {job_id}")); - } - actions.push("radroots runtime status get".to_owned()); - } else { - actions.push(format!("radroots job get {}", result.job_id)); - actions.push(format!("radroots job watch {}", result.job_id)); - } - - Ok(ListingMutationView { - state: if failed { - "unavailable".to_owned() - } else if result.deduplicated { - "deduplicated".to_owned() - } else { - result.status.clone() - }, - operation: operation.as_str().to_owned(), - source: LISTING_WRITE_SOURCE.to_owned(), - file: args.file.display().to_string(), - listing_id: canonical.listing_id, - listing_addr: listing_addr.clone(), - seller_pubkey: canonical.seller_pubkey.clone(), - event_kind: result.event_kind.unwrap_or(KIND_LISTING), - dry_run: false, - deduplicated: result.deduplicated, - job_id: Some(result.job_id.clone()), - job_status: Some(result.status.clone()), - signer_mode: Some(result.signer_mode.clone()), - signer_session_id: result.signer_session_id.clone(), - event_id: result.event_id.clone(), - event_addr: result - .event_addr - .clone() - .or_else(|| Some(listing_addr.clone())), - idempotency_key: result.idempotency_key.clone(), - requested_signer_session_id: args.signer_session_id.clone(), - reason: failed.then(|| { - "daemon publish job failed before relay delivery completed".to_owned() - }), - job: args.print_job.then(|| ListingMutationJobView { - rpc_method: "bridge.listing.publish".to_owned(), - state: result.status, - job_id: Some(result.job_id), - idempotency_key: result.idempotency_key, - requested_signer_session_id: args.signer_session_id.clone(), - signer_mode: Some(result.signer_mode), - signer_session_id: result.signer_session_id, - }), - event: args.print_event.then(|| ListingMutationEventView { - event_id: result.event_id, - event_addr: result.event_addr.unwrap_or(listing_addr), - ..event_preview - }), - actions, - }) - } - Err(error) => Ok(daemon_error_view( - config, - args, - operation, - &canonical, - listing_addr, - event_preview, - error, - )), - } + args, + operation, + &canonical, + listing_addr, + event_preview, + )) } fn scaffold_contents(draft: &ListingDraftDocument) -> Result<String, RuntimeError> { @@ -1289,172 +1176,18 @@ fn build_listing_event_preview( )) } -fn daemon_error_view( +fn direct_relay_unavailable_view( config: &RuntimeConfig, args: &ListingMutationArgs, operation: ListingMutationOperation, canonical: &CanonicalListingDraft, listing_addr: String, event_preview: ListingMutationEventView, - error: DaemonRpcError, ) -> ListingMutationView { - match error { - DaemonRpcError::Unconfigured(reason) - | DaemonRpcError::Unauthorized(reason) - | DaemonRpcError::MethodUnavailable(reason) => ListingMutationView { - state: "unconfigured".to_owned(), - operation: operation.as_str().to_owned(), - source: LISTING_WRITE_SOURCE.to_owned(), - file: args.file.display().to_string(), - listing_id: canonical.listing_id.clone(), - listing_addr, - seller_pubkey: canonical.seller_pubkey.clone(), - event_kind: KIND_LISTING, - dry_run: false, - deduplicated: false, - job_id: None, - job_status: None, - signer_mode: None, - signer_session_id: None, - event_id: None, - event_addr: None, - idempotency_key: args.idempotency_key.clone(), - requested_signer_session_id: args.signer_session_id.clone(), - reason: Some(reason), - job: args.print_job.then(|| ListingMutationJobView { - rpc_method: "bridge.listing.publish".to_owned(), - state: "unconfigured".to_owned(), - job_id: None, - idempotency_key: args.idempotency_key.clone(), - requested_signer_session_id: args.signer_session_id.clone(), - signer_mode: Some(config.signer.backend.as_str().to_owned()), - signer_session_id: None, - }), - event: args.print_event.then_some(event_preview), - actions: vec![ - "set RADROOTS_RPC_BEARER_TOKEN in .env or your shell".to_owned(), - "start radrootsd with bridge ingress enabled".to_owned(), - ], - }, - DaemonRpcError::External(reason) => ListingMutationView { - state: "unavailable".to_owned(), - operation: operation.as_str().to_owned(), - source: LISTING_WRITE_SOURCE.to_owned(), - file: args.file.display().to_string(), - listing_id: canonical.listing_id.clone(), - listing_addr, - seller_pubkey: canonical.seller_pubkey.clone(), - event_kind: KIND_LISTING, - dry_run: false, - deduplicated: false, - job_id: None, - job_status: None, - signer_mode: None, - signer_session_id: None, - event_id: None, - event_addr: None, - idempotency_key: args.idempotency_key.clone(), - requested_signer_session_id: args.signer_session_id.clone(), - reason: Some(reason), - job: args.print_job.then(|| ListingMutationJobView { - rpc_method: "bridge.listing.publish".to_owned(), - state: "unavailable".to_owned(), - job_id: None, - idempotency_key: args.idempotency_key.clone(), - requested_signer_session_id: args.signer_session_id.clone(), - signer_mode: Some(config.signer.backend.as_str().to_owned()), - signer_session_id: None, - }), - event: args.print_event.then_some(event_preview), - actions: vec!["start radrootsd and verify the rpc url".to_owned()], - }, - DaemonRpcError::InvalidResponse(reason) - | DaemonRpcError::Remote(reason) - | DaemonRpcError::UnknownJob(reason) => ListingMutationView { - state: "error".to_owned(), - operation: operation.as_str().to_owned(), - source: LISTING_WRITE_SOURCE.to_owned(), - file: args.file.display().to_string(), - listing_id: canonical.listing_id.clone(), - listing_addr, - seller_pubkey: canonical.seller_pubkey.clone(), - event_kind: KIND_LISTING, - dry_run: false, - deduplicated: false, - job_id: None, - job_status: None, - signer_mode: None, - signer_session_id: None, - event_id: None, - event_addr: None, - idempotency_key: args.idempotency_key.clone(), - requested_signer_session_id: args.signer_session_id.clone(), - reason: Some(reason), - job: args.print_job.then(|| ListingMutationJobView { - rpc_method: "bridge.listing.publish".to_owned(), - state: "error".to_owned(), - job_id: None, - idempotency_key: args.idempotency_key.clone(), - requested_signer_session_id: args.signer_session_id.clone(), - signer_mode: Some(config.signer.backend.as_str().to_owned()), - signer_session_id: None, - }), - event: args.print_event.then_some(event_preview), - actions: vec!["inspect the daemon rpc response contract".to_owned()], - }, - } -} - -fn local_signed_view( - config: &RuntimeConfig, - args: &ListingMutationArgs, - operation: ListingMutationOperation, - canonical: &CanonicalListingDraft, - listing_addr: String, - event_preview: ListingMutationEventView, -) -> Result<ListingMutationView, RuntimeError> { - let signed_event = match sign_listing_event(config, canonical) { - Ok(event) => event, - Err(error) => { - return Ok(ListingMutationView { - state: "unconfigured".to_owned(), - operation: operation.as_str().to_owned(), - source: LISTING_LOCAL_SIGNED_SOURCE.to_owned(), - file: args.file.display().to_string(), - listing_id: canonical.listing_id.clone(), - listing_addr, - seller_pubkey: canonical.seller_pubkey.clone(), - event_kind: KIND_LISTING, - dry_run: false, - deduplicated: false, - job_id: None, - job_status: None, - signer_mode: Some(config.signer.backend.as_str().to_owned()), - event_id: None, - event_addr: None, - idempotency_key: args.idempotency_key.clone(), - signer_session_id: None, - requested_signer_session_id: args.signer_session_id.clone(), - reason: Some(error.to_string()), - job: args.print_job.then(|| ListingMutationJobView { - rpc_method: "local.listing.sign".to_owned(), - state: "unconfigured".to_owned(), - job_id: None, - idempotency_key: args.idempotency_key.clone(), - requested_signer_session_id: args.signer_session_id.clone(), - signer_mode: Some(config.signer.backend.as_str().to_owned()), - signer_session_id: None, - }), - event: args.print_event.then_some(event_preview), - actions: vec!["radroots signer status get".to_owned()], - }); - } - }; - let event_view = signed_listing_event_view(&signed_event, listing_addr.as_str()); - Ok(ListingMutationView { - state: "signed".to_owned(), + ListingMutationView { + state: "unavailable".to_owned(), operation: operation.as_str().to_owned(), - source: LISTING_LOCAL_SIGNED_SOURCE.to_owned(), + source: LISTING_WRITE_SOURCE.to_owned(), file: args.file.display().to_string(), listing_id: canonical.listing_id.clone(), listing_addr: listing_addr.clone(), @@ -1465,38 +1198,16 @@ fn local_signed_view( job_id: None, job_status: None, signer_mode: Some(config.signer.backend.as_str().to_owned()), - event_id: event_view.event_id.clone(), + event_id: None, event_addr: Some(listing_addr), idempotency_key: args.idempotency_key.clone(), signer_session_id: None, requested_signer_session_id: args.signer_session_id.clone(), - reason: Some("signed locally; relay delivery was not attempted".to_owned()), - job: args.print_job.then(|| ListingMutationJobView { - rpc_method: "local.listing.sign".to_owned(), - state: "not_submitted".to_owned(), - job_id: None, - idempotency_key: args.idempotency_key.clone(), - requested_signer_session_id: args.signer_session_id.clone(), - signer_mode: Some(config.signer.backend.as_str().to_owned()), - signer_session_id: None, - }), - event: Some(event_view), + reason: Some(DIRECT_RELAY_UNAVAILABLE_REASON.to_owned()), + job: None, + event: args.print_event.then_some(event_preview), actions: Vec::new(), - }) -} - -fn sign_listing_event( - config: &RuntimeConfig, - canonical: &CanonicalListingDraft, -) -> Result<radroots_nostr::prelude::RadrootsNostrEvent, RuntimeError> { - let signing = resolve_listing_signing_identity(config, canonical)?; - let parts = to_wire_parts_with_kind(&canonical.listing, KIND_LISTING) - .map_err(|error| RuntimeError::Config(format!("invalid listing contract: {error}")))?; - let event = radroots_nostr_build_event(parts.kind, parts.content, parts.tags) - .map_err(|error| RuntimeError::Config(format!("build local listing event: {error}")))? - .sign_with_keys(signing.identity.keys()) - .map_err(|error| RuntimeError::Config(format!("sign local listing event: {error}")))?; - Ok(event) + } } fn validate_local_listing_signer( @@ -1526,26 +1237,6 @@ fn resolve_listing_signing_identity( Ok(signing) } -fn signed_listing_event_view( - event: &radroots_nostr::prelude::RadrootsNostrEvent, - listing_addr: &str, -) -> ListingMutationEventView { - ListingMutationEventView { - kind: event.kind.as_u16() as u32, - author: event.pubkey.to_string(), - created_at: Some(u32::try_from(event.created_at.as_secs()).unwrap_or(u32::MAX)), - content: event.content.clone(), - tags: event - .tags - .iter() - .map(|tag| tag.as_slice().to_vec()) - .collect(), - event_id: Some(event.id.to_string()), - signature: Some(event.sig.to_string()), - event_addr: listing_addr.to_owned(), - } -} - fn binding_error_view( config: &RuntimeConfig, args: &ListingMutationArgs, @@ -1583,15 +1274,7 @@ fn binding_error_view( idempotency_key: args.idempotency_key.clone(), requested_signer_session_id: args.signer_session_id.clone(), reason: Some(reason), - job: args.print_job.then(|| ListingMutationJobView { - rpc_method: "bridge.listing.publish".to_owned(), - state, - job_id: None, - idempotency_key: args.idempotency_key.clone(), - requested_signer_session_id: args.signer_session_id.clone(), - signer_mode: Some(config.signer.backend.as_str().to_owned()), - signer_session_id: None, - }), + job: None, event: args.print_event.then_some(event_preview), actions, } diff --git a/src/runtime_args.rs b/src/runtime_args.rs @@ -128,7 +128,6 @@ pub struct FarmPublishArgs { pub scope: Option<FarmScopeArg>, pub idempotency_key: Option<String>, pub signer_session_id: Option<String>, - pub print_job: bool, pub print_event: bool, } @@ -160,7 +159,6 @@ pub struct ListingMutationArgs { pub file: PathBuf, pub idempotency_key: Option<String>, pub signer_session_id: Option<String>, - pub print_job: bool, pub print_event: bool, } diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -2,12 +2,10 @@ mod support; use std::path::Path; -use radroots_events::kinds::KIND_LISTING; -use serde_json::json; use support::{ - RadrootsCliSandbox, assert_contains, assert_hex_len, assert_no_removed_command_reference, - create_listing_draft, identity_public, make_listing_publishable, shell_single_quoted, - toml_string, write_public_identity_profile, + RadrootsCliSandbox, assert_contains, assert_no_daemon_runtime_reference, + assert_no_removed_command_reference, create_listing_draft, identity_public, + make_listing_publishable, shell_single_quoted, toml_string, write_public_identity_profile, }; #[test] @@ -447,10 +445,10 @@ fn local_listing_publish_dry_run_validates_local_account_authority() { } #[test] -fn local_listing_publish_signs_with_selected_account_without_remote_fallback() { +fn local_listing_publish_fails_until_direct_relay_publish_exists() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]); - let listing_file = create_listing_draft(&sandbox, "local-signed"); + let listing_file = create_listing_draft(&sandbox, "local-unavailable"); make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw"); let (output, value) = sandbox.json_output(&[ @@ -463,41 +461,17 @@ fn local_listing_publish_signs_with_selected_account_without_remote_fallback() { listing_file.to_string_lossy().as_ref(), ]); - assert!(output.status.success()); + assert!(!output.status.success()); assert_eq!(value["operation_id"], "listing.publish"); - assert_eq!(value["result"]["state"], "signed"); - assert_eq!(value["result"]["signer_mode"], "local"); - assert_eq!( - value["result"]["signer_session_id"], - serde_json::Value::Null - ); - assert_eq!(value["result"]["job_id"], serde_json::Value::Null); - assert_eq!(value["result"]["event"]["kind"], KIND_LISTING); - assert_eq!( - value["result"]["event"]["author"], - value["result"]["seller_pubkey"] - ); - assert_eq!( - value["result"]["event"]["event_id"], - value["result"]["event_id"] - ); - assert_hex_len(&value["result"]["event_id"], 64); - assert_hex_len(&value["result"]["event"]["signature"], 128); + assert_eq!(value["result"], serde_json::Value::Null); + assert_eq!(value["errors"][0]["code"], "operation_unavailable"); + assert_eq!(value["errors"][0]["detail"]["class"], "operation"); assert_contains( - &value["result"]["reason"], - "relay delivery was not attempted", - ); - assert!( - value["result"]["event"]["tags"] - .as_array() - .expect("event tags") - .iter() - .any(|tag| tag - .as_array() - .is_some_and(|items| items.first() == Some(&json!("d")) - && items.get(1) == Some(&value["result"]["listing_id"]))) + &value["errors"][0]["message"], + "direct Nostr relay publishing is not implemented", ); assert_no_removed_command_reference(&value, &["listing", "publish"]); + assert_no_daemon_runtime_reference(&value, &["listing", "publish"]); } #[test] @@ -522,6 +496,7 @@ fn local_listing_publish_dry_run_does_not_sign_matching_listing() { assert_eq!(value["result"]["dry_run"], true); assert_eq!(value["result"]["event_id"], serde_json::Value::Null); assert_no_removed_command_reference(&value, &["listing", "publish", "--dry-run"]); + assert_no_daemon_runtime_reference(&value, &["listing", "publish", "--dry-run"]); } #[test] @@ -617,6 +592,48 @@ fn local_farm_publish_dry_run_validates_secret_backed_account() { assert_eq!(value["dry_run"], true); assert_eq!(value["result"]["state"], "dry_run"); assert_eq!(value["result"]["dry_run"], true); + assert_no_daemon_runtime_reference(&value, &["farm", "publish", "--dry-run"]); +} + +#[test] +fn local_farm_publish_fails_until_direct_relay_publish_exists() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + sandbox.json_success(&[ + "--format", + "json", + "farm", + "create", + "--name", + "Green Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--approval-token", + "approve", + "farm", + "publish", + ]); + + assert!(!output.status.success()); + assert_eq!(value["operation_id"], "farm.publish"); + assert_eq!(value["result"], serde_json::Value::Null); + assert_eq!(value["errors"][0]["code"], "operation_unavailable"); + assert_eq!(value["errors"][0]["detail"]["class"], "operation"); + assert_contains( + &value["errors"][0]["message"], + "direct Nostr relay publishing is not implemented", + ); + assert_no_removed_command_reference(&value, &["farm", "publish"]); + assert_no_daemon_runtime_reference(&value, &["farm", "publish"]); } #[test] diff --git a/tests/support/mod.rs b/tests/support/mod.rs @@ -166,6 +166,16 @@ pub fn assert_no_removed_command_reference(value: &Value, args: &[&str]) { } } +pub fn assert_no_daemon_runtime_reference(value: &Value, args: &[&str]) { + let raw = serde_json::to_string(value).expect("json value"); + for removed in ["radrootsd", "daemon", "bridge", "radroots job"] { + assert!( + !raw.contains(removed), + "`{args:?}` output should not contain daemon runtime reference `{removed}`: {raw}" + ); + } +} + pub fn assert_contains(value: &Value, needle: &str) { let value = value.as_str().expect("string value"); assert!( diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -6,8 +6,9 @@ use std::path::Path; use serde_json::Value; use support::{ - RadrootsCliSandbox, assert_no_removed_command_reference, create_listing_draft, identity_public, - make_listing_publishable, ndjson_from_stdout, radroots, write_public_identity_profile, + RadrootsCliSandbox, assert_no_daemon_runtime_reference, assert_no_removed_command_reference, + create_listing_draft, identity_public, make_listing_publishable, ndjson_from_stdout, radroots, + write_public_identity_profile, }; const LISTING_ADDR: &str = @@ -1207,6 +1208,7 @@ fn seller_target_flow_acceptance_uses_target_operations() { assert_eq!(publish["operation_id"], "listing.publish"); assert_eq!(publish["result"]["state"], "dry_run"); assert_no_removed_command_reference(&publish, &["listing", "publish", "--dry-run"]); + assert_no_daemon_runtime_reference(&publish, &["listing", "publish", "--dry-run"]); let archive = sandbox.json_success(&[ "--format", @@ -1220,8 +1222,9 @@ fn seller_target_flow_acceptance_uses_target_operations() { assert_eq!(archive["result"]["state"], "dry_run"); assert_eq!(archive["result"]["operation"], "archive"); assert_no_removed_command_reference(&archive, &["listing", "archive", "--dry-run"]); + assert_no_daemon_runtime_reference(&archive, &["listing", "archive", "--dry-run"]); - let signed = sandbox.json_success(&[ + let (publish_output, unavailable_publish) = sandbox.json_output(&[ "--format", "json", "--approval-token", @@ -1230,13 +1233,38 @@ fn seller_target_flow_acceptance_uses_target_operations() { "publish", listing_file, ]); - assert_eq!(signed["operation_id"], "listing.publish"); - assert_eq!(signed["result"]["state"], "signed"); - assert_eq!(signed["result"]["signer_mode"], "local"); + assert!(!publish_output.status.success()); + assert_eq!(unavailable_publish["operation_id"], "listing.publish"); assert_eq!( - signed["result"]["event"]["author"], - signed["result"]["seller_pubkey"] + unavailable_publish["errors"][0]["code"], + "operation_unavailable" ); - assert!(signed["result"]["event"]["signature"].is_string()); - assert_no_removed_command_reference(&signed, &["listing", "publish"]); + assert_eq!( + unavailable_publish["errors"][0]["detail"]["class"], + "operation" + ); + assert_no_removed_command_reference(&unavailable_publish, &["listing", "publish"]); + assert_no_daemon_runtime_reference(&unavailable_publish, &["listing", "publish"]); + + let (archive_output, unavailable_archive) = sandbox.json_output(&[ + "--format", + "json", + "--approval-token", + "approve", + "listing", + "archive", + listing_file, + ]); + assert!(!archive_output.status.success()); + assert_eq!(unavailable_archive["operation_id"], "listing.archive"); + assert_eq!( + unavailable_archive["errors"][0]["code"], + "operation_unavailable" + ); + assert_eq!( + unavailable_archive["errors"][0]["detail"]["class"], + "operation" + ); + assert_no_removed_command_reference(&unavailable_archive, &["listing", "archive"]); + assert_no_daemon_runtime_reference(&unavailable_archive, &["listing", "archive"]); }