cli

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

commit 1c640fead823200aa26d57707eddbe504d1c961d
parent 7e030b0414d2bad0e79a2b07a1b118e681f9231d
Author: triesap <tyson@radroots.org>
Date:   Thu,  7 May 2026 05:33:21 +0000

listing: decouple radrootsd writes from local accounts

- route listing mutation validation by publish mode
- keep local signer dry-run checks relay scoped
- require bridge auth for radrootsd dry-run preflight
- cover daemon listing writes without local accounts

Diffstat:
Msrc/runtime/listing.rs | 41++++++++++++++++++++++++++++++++++-------
Mtests/support/mod.rs | 29+++++++++++++++++++++++++++++
Mtests/target_cli.rs | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
3 files changed, 187 insertions(+), 9 deletions(-)

diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -849,7 +849,7 @@ fn mutate( args.file.display() )) })?; - let context = validation_context(config)?; + let context = mutation_validation_context(config)?; let mut canonical = canonicalize_draft(&parsed, &contents, &context).map_err(|error| { let issue = match error { ListingDraftValidationError::MissingSellerAccount(issue) => { @@ -880,12 +880,7 @@ fn mutate( let (event_draft, listing_addr) = build_listing_event_draft(&canonical)?; if config.output.dry_run - && matches!( - operation, - ListingMutationOperation::Publish - | ListingMutationOperation::Update - | ListingMutationOperation::Archive - ) + && matches!(config.publish.mode, PublishMode::NostrRelay) && matches!(config.signer.backend, SignerBackend::Local) { validate_local_listing_signer(config, &canonical)?; @@ -895,6 +890,18 @@ fn mutate( let requested_signer_session_id = match config.publish.mode { PublishMode::NostrRelay => args.signer_session_id.clone(), PublishMode::Radrootsd => { + if config.rpc.bridge_bearer_token.is_none() { + return Ok(radrootsd_preflight_view( + config, + args, + operation, + &canonical, + listing_addr, + event_draft.event, + "unconfigured", + "radrootsd bridge bearer token is required for listing publish dry-run; set RADROOTS_RPC_BEARER_TOKEN", + )); + } let Some(signer_session_id) = resolve_radrootsd_signer_session_id(config, args) else { return Ok(radrootsd_preflight_view( @@ -1298,6 +1305,26 @@ fn validation_context(config: &RuntimeConfig) -> Result<ListingValidationContext }) } +fn mutation_validation_context( + config: &RuntimeConfig, +) -> Result<ListingValidationContext, RuntimeError> { + match config.publish.mode { + PublishMode::NostrRelay => validation_context(config), + PublishMode::Radrootsd => radrootsd_mutation_validation_context(config), + } +} + +fn radrootsd_mutation_validation_context( + config: &RuntimeConfig, +) -> Result<ListingValidationContext, RuntimeError> { + Ok(ListingValidationContext { + selected_account_id: None, + selected_account_pubkey: None, + selected_farm_d_tag: None, + farm_setup_action: farm_setup_action(config)?, + }) +} + fn canonicalize_draft( draft: &ListingDraftDocument, contents: &str, diff --git a/tests/support/mod.rs b/tests/support/mod.rs @@ -381,6 +381,35 @@ pub fn make_listing_publishable(path: &Path, farm_d_tag: &str) { fs::write(path, format!("{patched}\n")).expect("write listing draft"); } +pub fn make_listing_publishable_with_seller(path: &Path, farm_d_tag: &str, seller_pubkey: &str) { + let raw = fs::read_to_string(path).expect("listing draft"); + let mut seller_pubkey_field_present = false; + let patched = raw + .lines() + .map(|line| { + let trimmed = line.trim_start(); + if trimmed.starts_with("seller_pubkey =") { + seller_pubkey_field_present = true; + format!("{}seller_pubkey = \"{}\"", line_indent(line), seller_pubkey) + } else if trimmed.starts_with("farm_d_tag =") { + format!("{}farm_d_tag = \"{}\"", line_indent(line), farm_d_tag) + } else if trimmed.starts_with("method =") { + format!("{}method = \"pickup\"", line_indent(line)) + } else if trimmed.starts_with("primary =") { + format!("{}primary = \"farmstand\"", line_indent(line)) + } else { + line.to_owned() + } + }) + .collect::<Vec<_>>() + .join("\n"); + assert!( + seller_pubkey_field_present, + "listing draft seller pubkey field" + ); + fs::write(path, format!("{patched}\n")).expect("write listing draft"); +} + pub fn shell_single_quoted(value: &str) -> String { value.replace('\'', "'\"'\"'") } diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -14,8 +14,8 @@ use serde_json::json; use support::{ RadrootsCliSandbox, assert_contains, assert_no_daemon_runtime_reference, assert_no_removed_command_reference, create_listing_draft, identity_public, - make_listing_publishable, ndjson_from_stdout, radroots, remove_orderable_listing, - replace_latest_listing_event_id, seed_orderable_listing, toml_string, + make_listing_publishable, make_listing_publishable_with_seller, ndjson_from_stdout, radroots, + remove_orderable_listing, replace_latest_listing_event_id, seed_orderable_listing, toml_string, write_public_identity_profile, }; @@ -557,6 +557,128 @@ signer_session_ref = "session_test" } #[test] +fn radrootsd_listing_writes_dry_run_use_draft_identity_without_local_account() { + for operation in ["publish", "update", "archive"] { + let sandbox = RadrootsCliSandbox::new(); + let seller = identity_public(42); + let listing_file = create_listing_draft( + &sandbox, + format!("radrootsd-no-account-dry-run-{operation}").as_str(), + ); + make_listing_publishable_with_seller( + &listing_file, + "AAAAAAAAAAAAAAAAAAAAAw", + seller.public_key_hex.as_str(), + ); + sandbox.write_app_config( + r#"[publish] +mode = "radrootsd" + +[[capability_binding]] +capability = "signer.remote_nip46" +provider = "myc" +target_kind = "explicit_endpoint" +target = "http://myc.invalid" +signer_session_ref = "session_test" +"#, + ); + + let mut command = sandbox.command(); + command + .env("RADROOTS_RPC_BEARER_TOKEN", "bridge_test") + .args([ + "--format", + "json", + "--account-id", + "missing-local-account", + "--dry-run", + "listing", + operation, + listing_file.to_string_lossy().as_ref(), + ]); + let output = command + .output() + .expect("run radrootsd dry-run listing write"); + let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); + + assert!(output.status.success()); + assert_eq!(value["operation_id"], format!("listing.{operation}")); + assert_eq!(value["result"]["state"], "dry_run"); + assert_eq!( + value["result"]["source"], + "radrootsd publish transport · signer session" + ); + assert_eq!(value["result"]["seller_pubkey"], seller.public_key_hex); + assert_eq!( + value["result"]["requested_signer_session_id"], + "session_test" + ); + assert_eq!(value["result"]["signer_mode"], "nip46"); + assert_eq!(value["errors"].as_array().expect("errors").len(), 0); + } +} + +#[test] +fn radrootsd_listing_writes_use_draft_identity_without_local_account() { + for operation in ["publish", "update", "archive"] { + let sandbox = RadrootsCliSandbox::new(); + let seller = identity_public(43); + let listing_file = create_listing_draft( + &sandbox, + format!("radrootsd-no-account-{operation}").as_str(), + ); + make_listing_publishable_with_seller( + &listing_file, + "AAAAAAAAAAAAAAAAAAAAAw", + seller.public_key_hex.as_str(), + ); + sandbox.write_app_config( + r#"[publish] +mode = "radrootsd" + +[[capability_binding]] +capability = "signer.remote_nip46" +provider = "myc" +target_kind = "explicit_endpoint" +target = "http://myc.invalid" +signer_session_ref = "session_test" +"#, + ); + let server = OneShotJsonRpcServer::listing_publish(); + + let mut command = sandbox.command(); + command + .env("RADROOTS_RPC_URL", &server.endpoint) + .env("RADROOTS_RPC_BEARER_TOKEN", "bridge_test") + .args([ + "--format", + "json", + "--account-id", + "missing-local-account", + "--approval-token", + "approve", + "listing", + operation, + listing_file.to_string_lossy().as_ref(), + ]); + let output = command.output().expect("run radrootsd listing write"); + let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); + let request = server.take_request(); + + assert!(output.status.success()); + assert_eq!(value["operation_id"], format!("listing.{operation}")); + assert_eq!( + value["result"]["source"], + "radrootsd publish transport · signer session" + ); + assert_eq!(value["result"]["seller_pubkey"], seller.public_key_hex); + assert_eq!(request.body["method"], "bridge.listing.publish"); + assert_eq!(request.body["params"]["signer_session_id"], "session_test"); + assert_eq!(value["errors"].as_array().expect("errors").len(), 0); + } +} + +#[test] fn radrootsd_listing_publish_bypasses_relay_signer_preflight() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]);