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