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:
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"]);