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