cli

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

commit 73cefddc6d9a7d2bde47a9b3341c5c0419889c02
parent 237e62d1e14374ec0d300b9c2495401509139917
Author: triesap <tyson@radroots.org>
Date:   Thu,  7 May 2026 02:56:45 +0000

cli: fail closed for radrootsd publish mode

- mark direct relay publish operations in the registry
- reject radrootsd mode before direct relay execution
- return structured publish mode unavailable detail
- preserve deferred payment preflight behavior

Diffstat:
Msrc/main.rs | 37++++++++++++++++++++++++++++++++++++-
Msrc/operation_registry.rs | 46++++++++++++++++++++++++++++++++++++++++++++++
Mtests/target_cli.rs | 35+++++++++++++++++++++++++++++++++++
3 files changed, 117 insertions(+), 1 deletion(-)

diff --git a/src/main.rs b/src/main.rs @@ -22,6 +22,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use clap::Parser; +use serde_json::json; use crate::deferred_payment::{deferred_payment_message, is_deferred_payment_operation}; use crate::operation_adapter::{ @@ -37,10 +38,11 @@ use crate::operation_market::MarketOperationService; use crate::operation_order::OrderOperationService; use crate::operation_registry::{ NetworkRequirement, network_requirement, requires_local_signer_mode, + requires_nostr_relay_publish_mode, }; use crate::operation_runtime::RuntimeOperationService; use crate::output_contract::OutputEnvelope; -use crate::runtime::config::{RuntimeConfig, SignerBackend}; +use crate::runtime::config::{PublishMode, RuntimeConfig, SignerBackend}; use crate::runtime::logging::initialize_logging; use crate::runtime_args::{RuntimeInvocationArgs, RuntimeOutputFormatArg}; use crate::target_cli::{TargetCliArgs, TargetOutputFormat}; @@ -352,6 +354,7 @@ fn validate_request_contract( ) -> Result<(), OperationAdapterError> { validate_pre_runtime_request_contract(request)?; validate_signer_mode_contract(request, config)?; + validate_publish_mode_contract(request, config)?; validate_network_contract(request, config)?; Ok(()) } @@ -448,6 +451,38 @@ fn validate_network_contract( } } +fn validate_publish_mode_contract( + request: &TargetOperationRequest, + config: &RuntimeConfig, +) -> Result<(), OperationAdapterError> { + let spec = request.spec(); + if matches!(config.publish.mode, PublishMode::Radrootsd) + && requires_nostr_relay_publish_mode(spec.operation_id) + { + return Err(OperationAdapterError::operation_unavailable_with_detail( + spec.operation_id, + format!( + "`{}` cannot run with publish mode `radrootsd`; radrootsd publish transport is not implemented", + spec.cli_path + ), + json!({ + "publish": { + "mode": config.publish.mode.as_str(), + "source": config.publish.source.as_str(), + "transport_family": config.publish.mode.transport_family(), + "state": "unavailable", + "executable": false, + "provider": { + "provider_runtime_id": "radrootsd", + "state": "unavailable", + } + } + }), + )); + } + Ok(()) +} + fn failure_envelope( request: &TargetOperationRequest, error: OperationAdapterError, diff --git a/src/operation_registry.rs b/src/operation_registry.rs @@ -1150,6 +1150,24 @@ pub fn requires_local_signer_mode(operation_id: &str) -> bool { ) } +pub fn requires_nostr_relay_publish_mode(operation_id: &str) -> bool { + matches!( + operation_id, + "farm.publish" + | "listing.publish" + | "listing.archive" + | "order.submit" + | "order.accept" + | "order.decline" + | "order.cancel" + | "order.revision.propose" + | "order.revision.accept" + | "order.revision.decline" + | "order.fulfillment.update" + | "order.receipt.record" + ) +} + pub fn registry_linkage_is_valid() -> bool { OPERATION_REGISTRY.iter().all(|operation| { get_operation(operation.operation_id).is_some() @@ -1166,6 +1184,7 @@ mod tests { use super::{ ApprovalPolicy, NetworkRequirement, OPERATION_REGISTRY, OperationRole, RiskLevel, get_operation, network_requirement, requires_local_signer_mode, + requires_nostr_relay_publish_mode, }; const EXPECTED_OPERATION_IDS: &[&str] = &[ @@ -1485,6 +1504,33 @@ mod tests { } #[test] + fn registry_nostr_relay_publish_requirements_are_explicit() { + let publish = OPERATION_REGISTRY + .iter() + .filter(|operation| requires_nostr_relay_publish_mode(operation.operation_id)) + .map(|operation| operation.operation_id) + .collect::<BTreeSet<_>>(); + let expected = [ + "farm.publish", + "listing.publish", + "listing.archive", + "order.submit", + "order.accept", + "order.decline", + "order.cancel", + "order.revision.propose", + "order.revision.accept", + "order.revision.decline", + "order.fulfillment.update", + "order.receipt.record", + ] + .into_iter() + .collect::<BTreeSet<_>>(); + + assert_eq!(publish, expected); + } + + #[test] fn deferred_namespaces_are_absent() { let namespaces = OPERATION_REGISTRY .iter() diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -140,6 +140,41 @@ fn health_check_exposes_publish_readiness() { } #[test] +fn radrootsd_publish_mode_fails_closed_for_direct_relay_publish_paths() { + let sandbox = RadrootsCliSandbox::new(); + let missing_listing = sandbox.root().join("missing-listing.toml"); + + 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(), + ]); + + 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" + ); +} + +#[test] fn removed_order_submit_watch_flag_is_rejected_publicly() { let output = radroots() .args(["order", "submit", "--watch"])