cli

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

commit 0f296d15ed0294fb71749d1a7cf92783814dc8bd
parent 390ed99bc5e4115456aa5ee94a3695794ebeda61
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 22:53:50 +0000

cli: require relay targets for seller publish

- reject non-dry seller publish commands without configured relays
- keep mutation approval checks ahead of relay target validation
- preserve distinct account-authority tests with explicit relay inputs
- update no-relay process assertions to expect network failures

Diffstat:
Msrc/operation_farm.rs | 21+++++++++++++++++++++
Msrc/operation_listing.rs | 22++++++++++++++++++++++
Mtests/signer_runtime_modes.rs | 22++++++++++++++--------
Mtests/target_cli.rs | 8++++----
4 files changed, 61 insertions(+), 12 deletions(-)

diff --git a/src/operation_farm.rs b/src/operation_farm.rs @@ -143,6 +143,7 @@ impl OperationService<FarmPublishRequest> for FarmOperationService<'_> { request.operation_id(), )); } + require_relay_target(&request, self.config)?; let view = crate::runtime::farm::publish(self.config, &args).map_err(|error| { OperationAdapterError::runtime_failure(request.operation_id(), error) @@ -235,6 +236,26 @@ fn farm_publish_result( } } +fn require_relay_target<P>( + request: &OperationRequest<P>, + config: &RuntimeConfig, +) -> Result<(), OperationAdapterError> +where + P: OperationRequestPayload, +{ + if request.context.dry_run || !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 map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapterError> { result.map_err(|error| OperationAdapterError::Runtime(error.to_string())) } diff --git a/src/operation_listing.rs b/src/operation_listing.rs @@ -144,6 +144,7 @@ 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); @@ -162,6 +163,7 @@ 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); @@ -214,6 +216,26 @@ 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/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -403,6 +403,8 @@ fn local_listing_publish_fails_without_local_account_authority() { let (output, value) = sandbox.json_output(&[ "--format", "json", + "--relay", + "ws://127.0.0.1:9", "--approval-token", "approve", "listing", @@ -445,7 +447,7 @@ fn local_listing_publish_dry_run_validates_local_account_authority() { } #[test] -fn local_listing_publish_fails_until_direct_relay_publish_exists() { +fn local_listing_publish_fails_without_configured_relay() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]); let listing_file = create_listing_draft(&sandbox, "local-unavailable"); @@ -464,11 +466,11 @@ fn local_listing_publish_fails_until_direct_relay_publish_exists() { assert!(!output.status.success()); assert_eq!(value["operation_id"], "listing.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_eq!(value["errors"][0]["code"], "network_unavailable"); + assert_eq!(value["errors"][0]["detail"]["class"], "network"); assert_contains( &value["errors"][0]["message"], - "direct Nostr relay publishing is not implemented", + "requires at least one configured relay", ); assert_no_removed_command_reference(&value, &["listing", "publish"]); assert_no_daemon_runtime_reference(&value, &["listing", "publish"]); @@ -545,6 +547,8 @@ fn local_listing_publish_fails_when_selected_account_does_not_match_seller() { let (output, value) = sandbox.json_output(&[ "--format", "json", + "--relay", + "ws://127.0.0.1:9", "--account-id", second_account_id, "--approval-token", @@ -596,7 +600,7 @@ fn local_farm_publish_dry_run_validates_secret_backed_account() { } #[test] -fn local_farm_publish_fails_until_direct_relay_publish_exists() { +fn local_farm_publish_fails_without_configured_relay() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]); sandbox.json_success(&[ @@ -626,11 +630,11 @@ fn local_farm_publish_fails_until_direct_relay_publish_exists() { 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_eq!(value["errors"][0]["code"], "network_unavailable"); + assert_eq!(value["errors"][0]["detail"]["class"], "network"); assert_contains( &value["errors"][0]["message"], - "direct Nostr relay publishing is not implemented", + "requires at least one configured relay", ); assert_no_removed_command_reference(&value, &["farm", "publish"]); assert_no_daemon_runtime_reference(&value, &["farm", "publish"]); @@ -698,6 +702,8 @@ fn watch_only_listing_publish_fails_as_account_watch_only() { let (output, value) = sandbox.json_output(&[ "--format", "json", + "--relay", + "ws://127.0.0.1:9", "--approval-token", "approve", "listing", diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -1285,11 +1285,11 @@ fn seller_target_flow_acceptance_uses_target_operations() { assert_eq!(unavailable_publish["operation_id"], "listing.publish"); assert_eq!( unavailable_publish["errors"][0]["code"], - "operation_unavailable" + "network_unavailable" ); assert_eq!( unavailable_publish["errors"][0]["detail"]["class"], - "operation" + "network" ); assert_no_removed_command_reference(&unavailable_publish, &["listing", "publish"]); assert_no_daemon_runtime_reference(&unavailable_publish, &["listing", "publish"]); @@ -1307,11 +1307,11 @@ fn seller_target_flow_acceptance_uses_target_operations() { assert_eq!(unavailable_archive["operation_id"], "listing.archive"); assert_eq!( unavailable_archive["errors"][0]["code"], - "operation_unavailable" + "network_unavailable" ); assert_eq!( unavailable_archive["errors"][0]["detail"]["class"], - "operation" + "network" ); assert_no_removed_command_reference(&unavailable_archive, &["listing", "archive"]); assert_no_daemon_runtime_reference(&unavailable_archive, &["listing", "archive"]);