cli

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

commit 99317d2f2c676f3186a6a5dae1aee2836b23be23
parent 91ce165c44c6c3904c4af74dbc1d58d2cd2a2797
Author: triesap <tyson@radroots.org>
Date:   Thu,  7 May 2026 04:16:30 +0000

cli: add listing publish-mode router

- route listing write preflight through publish mode
- remove listing writes from the global relay-only gate
- keep relay-only checks inside listing execution
- cover radrootsd listing routing with process tests

Diffstat:
Msrc/main.rs | 23++++++++++++++++++++++-
Msrc/operation_listing.rs | 22----------------------
Msrc/operation_registry.rs | 6------
Msrc/runtime/listing.rs | 106++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mtests/target_cli.rs | 127++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
5 files changed, 207 insertions(+), 77 deletions(-)

diff --git a/src/main.rs b/src/main.rs @@ -394,7 +394,7 @@ fn validate_signer_mode_contract( ) -> Result<(), OperationAdapterError> { let spec = request.spec(); if matches!(config.signer.backend, SignerBackend::Myc) - && requires_local_signer_mode(spec.operation_id) + && requires_local_signer_mode_for_publish_mode(spec.operation_id, config) { return Err(OperationAdapterError::SignerModeDeferred { operation_id: spec.operation_id.to_owned(), @@ -436,6 +436,7 @@ fn validate_network_contract( dry_run_requires_network, } = requirement && (!request.context().dry_run || dry_run_requires_network) + && requires_pre_runtime_relay_target(spec.operation_id) && config.relay.urls.is_empty() { return Err(OperationAdapterError::NetworkUnavailable { @@ -451,6 +452,19 @@ fn validate_network_contract( } } +fn requires_local_signer_mode_for_publish_mode(operation_id: &str, config: &RuntimeConfig) -> bool { + if matches!(config.publish.mode, PublishMode::Radrootsd) + && is_listing_publish_mode_routed_operation(operation_id) + { + return false; + } + requires_local_signer_mode(operation_id) +} + +fn requires_pre_runtime_relay_target(operation_id: &str) -> bool { + !is_listing_publish_mode_routed_operation(operation_id) +} + fn validate_publish_mode_contract( request: &TargetOperationRequest, config: &RuntimeConfig, @@ -483,6 +497,13 @@ fn validate_publish_mode_contract( Ok(()) } +fn is_listing_publish_mode_routed_operation(operation_id: &str) -> bool { + matches!( + operation_id, + "listing.publish" | "listing.update" | "listing.archive" + ) +} + fn failure_envelope( request: &TargetOperationRequest, error: OperationAdapterError, diff --git a/src/operation_listing.rs b/src/operation_listing.rs @@ -150,7 +150,6 @@ impl OperationService<ListingPublishRequest> for ListingOperationService<'_> { ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { if !request.context.dry_run { require_approval(&request)?; - require_relay_target(&request, self.config)?; } let args = mutation_args(&request)?; let config = mutation_config(self.config, &request); @@ -170,7 +169,6 @@ impl OperationService<ListingArchiveRequest> for ListingOperationService<'_> { ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { if !request.context.dry_run { require_approval(&request)?; - require_relay_target(&request, self.config)?; } let args = mutation_args(&request)?; let config = mutation_config(self.config, &request); @@ -223,26 +221,6 @@ where Ok(()) } -fn require_relay_target<P>( - request: &OperationRequest<P>, - config: &RuntimeConfig, -) -> Result<(), OperationAdapterError> -where - P: OperationRequestPayload, -{ - if !config.relay.urls.is_empty() { - return Ok(()); - } - - Err(OperationAdapterError::NetworkUnavailable { - operation_id: request.operation_id().to_owned(), - message: format!( - "`{}` requires at least one configured relay for direct relay publication", - request.spec.cli_path - ), - }) -} - fn serialized_operation_result<R, T>(value: &T) -> Result<OperationResult<R>, OperationAdapterError> where R: OperationResultData, diff --git a/src/operation_registry.rs b/src/operation_registry.rs @@ -1154,9 +1154,6 @@ pub fn requires_nostr_relay_publish_mode(operation_id: &str) -> bool { matches!( operation_id, "farm.publish" - | "listing.publish" - | "listing.update" - | "listing.archive" | "order.submit" | "order.accept" | "order.decline" @@ -1513,9 +1510,6 @@ mod tests { .collect::<BTreeSet<_>>(); let expected = [ "farm.publish", - "listing.publish", - "listing.update", - "listing.archive", "order.submit", "order.accept", "order.decline", diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -33,7 +33,7 @@ use crate::domain::runtime::{ }; use crate::runtime::RuntimeError; use crate::runtime::accounts; -use crate::runtime::config::{RuntimeConfig, SignerBackend}; +use crate::runtime::config::{PublishMode, RuntimeConfig, SignerBackend}; use crate::runtime::direct_relay::{ DirectRelayFailure, DirectRelayPublishReceipt, publish_parts_with_identity, }; @@ -47,9 +47,12 @@ 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 = "direct Nostr relay publish · local key"; +const RELAY_LISTING_WRITE_SOURCE: &str = "direct Nostr relay publish · local key"; +const RADROOTSD_LISTING_WRITE_SOURCE: &str = "radrootsd publish transport · signer session"; const DIRECT_RELAY_UNAVAILABLE_REASON: &str = "direct Nostr relay publishing is not implemented for listing update"; +const RADROOTSD_LISTING_UNAVAILABLE_REASON: &str = + "radrootsd listing publish transport is not implemented"; const LISTING_DRAFTS_DIR: &str = "listings/drafts"; static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -883,7 +886,7 @@ fn mutate( return Ok(ListingMutationView { state: "dry_run".to_owned(), operation: operation.as_str().to_owned(), - source: LISTING_WRITE_SOURCE.to_owned(), + source: listing_write_source(config).to_owned(), file: args.file.display().to_string(), listing_id: canonical.listing_id.clone(), listing_addr: listing_addr.clone(), @@ -902,7 +905,7 @@ 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; relay publish skipped".to_owned()), + reason: Some(dry_run_reason(config)), job: None, event: args.print_event.then_some(event_draft.event), actions: vec![format!( @@ -913,19 +916,47 @@ fn mutate( }); } + match config.publish.mode { + PublishMode::NostrRelay => mutate_via_direct_relay( + config, + args, + operation, + &canonical, + listing_addr, + event_draft, + ), + PublishMode::Radrootsd => Ok(radrootsd_unavailable_view( + config, + args, + operation, + &canonical, + listing_addr, + event_draft.event, + )), + } +} + +fn mutate_via_direct_relay( + config: &RuntimeConfig, + args: &ListingMutationArgs, + operation: ListingMutationOperation, + canonical: &CanonicalListingDraft, + listing_addr: String, + event_draft: ListingMutationEventDraft, +) -> Result<ListingMutationView, RuntimeError> { if matches!(operation, ListingMutationOperation::Update) { return Ok(direct_relay_unavailable_view( config, args, operation, - &canonical, + canonical, listing_addr, event_draft.event, )); } let signing = if matches!(config.signer.backend, SignerBackend::Local) { - resolve_listing_signing_identity(config, &canonical)? + resolve_listing_signing_identity(config, canonical)? } else { match resolve_actor_write_authority(config, "seller", canonical.seller_pubkey.as_str()) { Ok(_) => { @@ -933,7 +964,7 @@ fn mutate( config, args, operation, - &canonical, + canonical, listing_addr, event_draft.event, ActorWriteBindingError::Unconfigured( @@ -946,7 +977,7 @@ fn mutate( config, args, operation, - &canonical, + canonical, listing_addr, event_draft.event, error, @@ -963,13 +994,27 @@ fn mutate( config, args, operation, - &canonical, + canonical, listing_addr, event_draft.event, receipt, )) } +fn listing_write_source(config: &RuntimeConfig) -> &'static str { + match config.publish.mode { + PublishMode::NostrRelay => RELAY_LISTING_WRITE_SOURCE, + PublishMode::Radrootsd => RADROOTSD_LISTING_WRITE_SOURCE, + } +} + +fn dry_run_reason(config: &RuntimeConfig) -> String { + match config.publish.mode { + PublishMode::NostrRelay => "dry run requested; relay publish skipped".to_owned(), + PublishMode::Radrootsd => "dry run requested; radrootsd submission skipped".to_owned(), + } +} + fn scaffold_contents(draft: &ListingDraftDocument) -> Result<String, RuntimeError> { let toml = toml::to_string_pretty(draft).map_err(|error| { RuntimeError::Config(format!("failed to render listing draft: {error}")) @@ -1425,7 +1470,7 @@ fn direct_relay_unavailable_view( ListingMutationView { state: "unavailable".to_owned(), operation: operation.as_str().to_owned(), - source: LISTING_WRITE_SOURCE.to_owned(), + source: listing_write_source(config).to_owned(), file: args.file.display().to_string(), listing_id: canonical.listing_id.clone(), listing_addr: listing_addr.clone(), @@ -1451,6 +1496,43 @@ fn direct_relay_unavailable_view( } } +fn radrootsd_unavailable_view( + config: &RuntimeConfig, + args: &ListingMutationArgs, + operation: ListingMutationOperation, + canonical: &CanonicalListingDraft, + listing_addr: String, + event_preview: ListingMutationEventView, +) -> ListingMutationView { + ListingMutationView { + state: "unavailable".to_owned(), + operation: operation.as_str().to_owned(), + source: listing_write_source(config).to_owned(), + file: args.file.display().to_string(), + listing_id: canonical.listing_id.clone(), + listing_addr: listing_addr.clone(), + seller_pubkey: canonical.seller_pubkey.clone(), + event_kind: KIND_LISTING, + dry_run: false, + deduplicated: false, + target_relays: Vec::new(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), + job_id: None, + job_status: None, + signer_mode: Some(config.signer.backend.as_str().to_owned()), + 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(RADROOTSD_LISTING_UNAVAILABLE_REASON.to_owned()), + job: None, + event: args.print_event.then_some(event_preview), + actions: Vec::new(), + } +} + fn validate_local_listing_signer( config: &RuntimeConfig, canonical: &CanonicalListingDraft, @@ -1495,7 +1577,7 @@ fn binding_error_view( ListingMutationView { state: state.clone(), operation: operation.as_str().to_owned(), - source: LISTING_WRITE_SOURCE.to_owned(), + source: listing_write_source(config).to_owned(), file: args.file.display().to_string(), listing_id: canonical.listing_id.clone(), listing_addr, @@ -1548,7 +1630,7 @@ fn published_mutation_view( } .to_owned(), operation: operation.as_str().to_owned(), - source: LISTING_WRITE_SOURCE.to_owned(), + source: listing_write_source(config).to_owned(), file: args.file.display().to_string(), listing_id: canonical.listing_id.clone(), listing_addr: listing_addr.clone(), diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -305,44 +305,83 @@ fn health_check_marks_relay_publish_ready_with_secret_backed_local_account() { } #[test] -fn radrootsd_publish_mode_fails_closed_for_direct_relay_publish_paths() { +fn radrootsd_listing_publish_reaches_listing_router_without_relay_config() { let sandbox = RadrootsCliSandbox::new(); - let missing_listing = sandbox.root().join("missing-listing.toml"); + sandbox.json_success(&["--format", "json", "account", "create"]); + let farm = sandbox.json_success(&[ + "--format", + "json", + "farm", + "create", + "--name", + "Router Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + let listing_file = create_listing_draft(&sandbox, "radrootsd-router"); + make_listing_publishable( + &listing_file, + farm["result"]["config"]["farm_d_tag"] + .as_str() + .expect("farm d tag"), + ); let (output, value) = sandbox.json_output(&[ "--format", "json", "--publish-mode", "radrootsd", - "--relay", - "wss://relay.example.test", "--approval-token", "approve", "listing", "publish", - missing_listing.to_string_lossy().as_ref(), + listing_file.to_string_lossy().as_ref(), ]); assert!(!output.status.success()); assert_eq!(output.status.code(), Some(3)); assert_eq!(value["operation_id"], "listing.publish"); assert_eq!(value["result"], Value::Null); - assert_eq!(value["errors"][0]["code"], "operation_unavailable"); - assert_eq!(value["errors"][0]["detail"]["publish"]["mode"], "radrootsd"); - assert_eq!( - value["errors"][0]["detail"]["publish"]["provider"]["provider_runtime_id"], - "radrootsd" - ); - assert_eq!( - value["errors"][0]["detail"]["publish"]["provider"]["state"], - "unavailable" + assert_eq!(value["errors"][0]["code"], "provider_unavailable"); + assert_eq!(value["errors"][0]["detail"]["class"], "provider"); + assert_contains(&value["errors"][0]["message"], "radrootsd listing publish"); + assert!( + !value["errors"][0]["message"] + .as_str() + .expect("error message") + .contains("configured relay") ); } #[test] -fn radrootsd_publish_mode_takes_precedence_over_deferred_signer_mode() { +fn radrootsd_listing_publish_bypasses_relay_signer_preflight() { let sandbox = RadrootsCliSandbox::new(); - let missing_listing = sandbox.root().join("missing-listing.toml"); + sandbox.json_success(&["--format", "json", "account", "create"]); + let farm = sandbox.json_success(&[ + "--format", + "json", + "farm", + "create", + "--name", + "Deferred Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + let listing_file = create_listing_draft(&sandbox, "radrootsd-myc-router"); + make_listing_publishable( + &listing_file, + farm["result"]["config"]["farm_d_tag"] + .as_str() + .expect("farm d tag"), + ); sandbox.write_app_config("[publish]\nmode = \"radrootsd\"\n\n[signer]\nmode = \"myc\"\n"); let (output, value) = sandbox.json_output(&[ @@ -352,25 +391,48 @@ fn radrootsd_publish_mode_takes_precedence_over_deferred_signer_mode() { "approve", "listing", "publish", - missing_listing.to_string_lossy().as_ref(), + listing_file.to_string_lossy().as_ref(), ]); assert!(!output.status.success()); assert_eq!(output.status.code(), Some(3)); assert_eq!(value["operation_id"], "listing.publish"); - assert_eq!(value["errors"][0]["code"], "operation_unavailable"); - assert_eq!(value["errors"][0]["detail"]["class"], "operation"); - assert_eq!(value["errors"][0]["detail"]["publish"]["mode"], "radrootsd"); - assert_eq!( - value["errors"][0]["detail"]["publish"]["provider"]["state"], - "unavailable" + assert_eq!(value["errors"][0]["code"], "provider_unavailable"); + assert_eq!(value["errors"][0]["detail"]["class"], "provider"); + assert_contains(&value["errors"][0]["message"], "radrootsd listing publish"); + assert!( + !value["errors"][0]["message"] + .as_str() + .expect("error message") + .contains("signer mode `myc`") ); } #[test] -fn radrootsd_publish_mode_fails_closed_for_listing_update() { +fn radrootsd_publish_mode_routes_listing_update() { let sandbox = RadrootsCliSandbox::new(); - let missing_listing = sandbox.root().join("missing-listing.toml"); + sandbox.json_success(&["--format", "json", "account", "create"]); + let farm = sandbox.json_success(&[ + "--format", + "json", + "farm", + "create", + "--name", + "Update Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + let listing_file = create_listing_draft(&sandbox, "radrootsd-update-router"); + make_listing_publishable( + &listing_file, + farm["result"]["config"]["farm_d_tag"] + .as_str() + .expect("farm d tag"), + ); let (output, value) = sandbox.json_output(&[ "--format", @@ -379,23 +441,16 @@ fn radrootsd_publish_mode_fails_closed_for_listing_update() { "radrootsd", "listing", "update", - missing_listing.to_string_lossy().as_ref(), + listing_file.to_string_lossy().as_ref(), ]); assert!(!output.status.success()); assert_eq!(output.status.code(), Some(3)); assert_eq!(value["operation_id"], "listing.update"); assert_eq!(value["result"], Value::Null); - assert_eq!(value["errors"][0]["code"], "operation_unavailable"); - assert_eq!(value["errors"][0]["detail"]["publish"]["mode"], "radrootsd"); - assert_eq!( - value["errors"][0]["detail"]["publish"]["provider"]["provider_runtime_id"], - "radrootsd" - ); - assert_eq!( - value["errors"][0]["detail"]["publish"]["provider"]["state"], - "unavailable" - ); + assert_eq!(value["errors"][0]["code"], "provider_unavailable"); + assert_eq!(value["errors"][0]["detail"]["class"], "provider"); + assert_contains(&value["errors"][0]["message"], "radrootsd listing publish"); } #[test]