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