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