cli

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

commit 5fcb1aa3ff6615a72c134bdd39e832b81a84c93e
parent 1bf01554c5ecd3778dbe4486d898d4443268961f
Author: triesap <tyson@radroots.org>
Date:   Thu,  7 May 2026 04:52:50 +0000

cli: activate listing update publish

- require approval before non-dry listing update writes
- route update through the selected listing publish transport
- validate update dry-runs against local signer authority
- cover update approval and authority regressions

Diffstat:
Msrc/operation_listing.rs | 3+++
Msrc/runtime/listing.rs | 85+++++++++++++++++++++++++++++--------------------------------------------------
Mtests/signer_runtime_modes.rs | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/target_cli.rs | 23++++++++++++++++++-----
4 files changed, 143 insertions(+), 59 deletions(-)

diff --git a/src/operation_listing.rs b/src/operation_listing.rs @@ -113,6 +113,9 @@ impl OperationService<ListingUpdateRequest> for ListingOperationService<'_> { &self, request: OperationRequest<ListingUpdateRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + if !request.context.dry_run { + require_approval(&request)?; + } let args = mutation_args(&request)?; let config = mutation_config(self.config, &request); let view = map_runtime( diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -59,8 +59,6 @@ const LISTING_SOURCE: &str = "local draft · local first"; const LISTING_READ_SOURCE: &str = "local replica · local first"; 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_BRIDGE_LISTING_PUBLISH_METHOD: &str = "bridge.listing.publish"; const LISTING_DRAFTS_DIR: &str = "listings/drafts"; @@ -884,7 +882,9 @@ fn mutate( if config.output.dry_run && matches!( operation, - ListingMutationOperation::Publish | ListingMutationOperation::Archive + ListingMutationOperation::Publish + | ListingMutationOperation::Update + | ListingMutationOperation::Archive ) && matches!(config.signer.backend, SignerBackend::Local) { @@ -892,6 +892,25 @@ fn mutate( } if config.output.dry_run { + let requested_signer_session_id = match config.publish.mode { + PublishMode::NostrRelay => args.signer_session_id.clone(), + PublishMode::Radrootsd => { + let Some(signer_session_id) = resolve_radrootsd_signer_session_id(config, args) + else { + return Ok(radrootsd_preflight_view( + config, + args, + operation, + &canonical, + listing_addr, + event_draft.event, + "unconfigured", + "radrootsd listing publish dry-run requires `signer_session_id` input or a signer.remote_nip46 capability binding with signer_session_ref", + )); + }; + Some(signer_session_id) + } + }; return Ok(ListingMutationView { state: "dry_run".to_owned(), operation: operation.as_str().to_owned(), @@ -909,12 +928,12 @@ fn mutate( failed_relays: Vec::new(), job_id: None, job_status: None, - signer_mode: None, + signer_mode: dry_run_signer_mode(config), event_id: None, event_addr: Some(listing_addr.clone()), idempotency_key: args.idempotency_key.clone(), signer_session_id: None, - requested_signer_session_id: args.signer_session_id.clone(), + requested_signer_session_id, reason: Some(dry_run_reason(config)), job: None, event: args.print_event.then_some(event_draft.event), @@ -954,17 +973,6 @@ fn mutate_via_direct_relay( listing_addr: String, event_draft: ListingMutationEventDraft, ) -> Result<ListingMutationView, RuntimeError> { - if matches!(operation, ListingMutationOperation::Update) { - return Ok(direct_relay_unavailable_view( - config, - args, - operation, - canonical, - listing_addr, - event_draft.event, - )); - } - let signing = if matches!(config.signer.backend, SignerBackend::Local) { resolve_listing_signing_identity(config, canonical)? } else { @@ -1254,6 +1262,13 @@ fn dry_run_reason(config: &RuntimeConfig) -> String { } } +fn dry_run_signer_mode(config: &RuntimeConfig) -> Option<String> { + match config.publish.mode { + PublishMode::NostrRelay => None, + PublishMode::Radrootsd => Some("nip46".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}")) @@ -1698,44 +1713,6 @@ fn build_listing_event_draft( )) } -fn direct_relay_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(), - connected_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(DIRECT_RELAY_UNAVAILABLE_REASON.to_owned()), - job: None, - event: args.print_event.then_some(event_preview), - actions: Vec::new(), - } -} - fn radrootsd_preflight_view( config: &RuntimeConfig, args: &ListingMutationArgs, diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -753,6 +753,60 @@ fn local_listing_publish_dry_run_validates_local_account_authority() { } #[test] +fn local_listing_update_dry_run_validates_local_account_authority() { + let sandbox = RadrootsCliSandbox::new(); + let listing_file = create_listing_draft(&sandbox, "local-update-dry-run-no-account"); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--dry-run", + "listing", + "update", + listing_file.to_string_lossy().as_ref(), + ]); + + assert!(!output.status.success()); + assert_eq!(value["operation_id"], "listing.update"); + assert_eq!(value["result"], serde_json::Value::Null); + assert_eq!(value["errors"][0]["code"], "account_unresolved"); + assert_eq!(value["errors"][0]["detail"]["class"], "account"); + assert_no_removed_command_reference(&value, &["listing", "update", "--dry-run"]); +} + +#[test] +fn local_listing_update_dry_run_rejects_mismatched_local_account() { + let sandbox = RadrootsCliSandbox::new(); + let first = sandbox.json_success(&["--format", "json", "account", "create"]); + let listing_file = create_listing_draft(&sandbox, "local-update-dry-run-mismatch"); + make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw"); + let second = sandbox.json_success(&["--format", "json", "account", "create"]); + let second_account_id = second["result"]["account"]["id"] + .as_str() + .expect("second account id"); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--account-id", + second_account_id, + "--dry-run", + "listing", + "update", + listing_file.to_string_lossy().as_ref(), + ]); + + assert_ne!( + first["result"]["account"]["id"], + second["result"]["account"]["id"] + ); + assert!(!output.status.success()); + assert_eq!(value["operation_id"], "listing.update"); + assert_eq!(value["errors"][0]["code"], "account_mismatch"); + assert_eq!(value["errors"][0]["detail"]["class"], "account"); +} + +#[test] fn local_listing_publish_fails_without_configured_relay() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]); @@ -1314,6 +1368,43 @@ fn watch_only_listing_publish_fails_as_account_watch_only() { assert_contains(&value["errors"][0]["message"], "watch_only"); } +#[test] +fn watch_only_listing_update_dry_run_fails_as_account_watch_only() { + let sandbox = RadrootsCliSandbox::new(); + let public_identity = identity_public(13); + let public_identity_file = + write_public_identity_profile(&sandbox, "watch-only-update", &public_identity); + sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "import", + "--default", + public_identity_file.to_string_lossy().as_ref(), + ]); + let listing_file = create_listing_draft(&sandbox, "watch-only-update"); + make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw"); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--dry-run", + "listing", + "update", + listing_file.to_string_lossy().as_ref(), + ]); + + assert!(!output.status.success()); + assert_eq!(value["operation_id"], "listing.update"); + assert_eq!(value["result"], serde_json::Value::Null); + assert_eq!(value["errors"][0]["code"], "account_watch_only"); + assert_eq!(value["errors"][0]["exit_code"], 7); + assert_eq!(value["errors"][0]["detail"]["class"], "account"); + assert_contains(&value["errors"][0]["message"], "watch_only"); +} + #[cfg(unix)] #[test] fn myc_listing_publish_does_not_fallback_to_local_account() { diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -596,6 +596,8 @@ fn radrootsd_publish_mode_routes_listing_update() { "json", "--publish-mode", "radrootsd", + "--approval-token", + "approve", "listing", "update", listing_file.to_string_lossy().as_ref(), @@ -611,7 +613,7 @@ fn radrootsd_publish_mode_routes_listing_update() { } #[test] -fn listing_update_unavailable_support_exits_nonzero() { +fn listing_update_publish_attempts_direct_relay_with_approval() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]); let farm = sandbox.json_success(&[ @@ -641,21 +643,27 @@ fn listing_update_unavailable_support_exits_nonzero() { "json", "--relay", "ws://127.0.0.1:9", + "--approval-token", + "approve", "listing", "update", 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]["code"], "network_unavailable"); + assert_eq!(value["errors"][0]["detail"]["class"], "network"); + assert_contains( + &value["errors"][0]["message"], + "direct relay connection failed", + ); assert!( - value["errors"][0]["message"] + !value["errors"][0]["message"] .as_str() .expect("error message") - .contains("direct Nostr relay publishing is not implemented for listing update") + .contains("not implemented") ); assert_no_removed_command_reference(&value, &["listing", "update"]); assert_no_daemon_runtime_reference(&value, &["listing", "update"]); @@ -2241,6 +2249,11 @@ fn required_approval_token_rejects_absent_empty_and_whitespace_values() { ); assert_required_approval_token_rejected( &sandbox, + "listing.update", + &["listing", "update", "missing-listing.toml"], + ); + assert_required_approval_token_rejected( + &sandbox, "listing.archive", &["listing", "archive", "missing-listing.toml"], );