commit e6a4e95b04b2fcfa016557b96f6cf5c20c7e6799
parent d9f1278eae3ab6e5fd1b6a8f7b8cde24b609a6bb
Author: triesap <tyson@radroots.org>
Date: Thu, 9 Apr 2026 20:31:36 +0000
signer: align actor-authored writes with composed bindings
Diffstat:
| M | src/runtime/listing.rs | | | 77 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/runtime/order.rs | | | 61 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/runtime/signer.rs | | | 179 | ++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------- |
| M | tests/listing.rs | | | 316 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | tests/order.rs | | | 316 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- |
5 files changed, 881 insertions(+), 68 deletions(-)
diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs
@@ -33,6 +33,7 @@ use crate::runtime::accounts;
use crate::runtime::config::RuntimeConfig;
use crate::runtime::daemon;
use crate::runtime::daemon::DaemonRpcError;
+use crate::runtime::signer::{ActorWriteBindingError, validate_actor_write_binding};
use crate::runtime::sync::freshness_from_executor;
const DRAFT_KIND: &str = "listing_draft_v1";
@@ -543,6 +544,20 @@ fn mutate(
});
}
+ if let Err(error) =
+ validate_actor_write_binding(config, "seller", canonical.seller_pubkey.as_str())
+ {
+ return Ok(binding_error_view(
+ config,
+ args,
+ operation,
+ &canonical,
+ listing_addr,
+ event_preview,
+ error,
+ ));
+ }
+
let signer_session_id = match daemon::resolve_signer_session_id(
config,
"seller",
@@ -1091,6 +1106,68 @@ fn daemon_error_view(
}
}
+fn binding_error_view(
+ config: &RuntimeConfig,
+ args: &ListingMutationArgs,
+ operation: ListingMutationOperation,
+ canonical: &CanonicalListingDraft,
+ listing_addr: String,
+ event_preview: ListingMutationEventView,
+ error: ActorWriteBindingError,
+) -> ListingMutationView {
+ let (state, reason, actions) = match error {
+ ActorWriteBindingError::Unconfigured(reason) => (
+ "unconfigured".to_owned(),
+ reason,
+ vec![
+ "radroots --signer myc signer status".to_owned(),
+ "radroots rpc sessions".to_owned(),
+ ],
+ ),
+ ActorWriteBindingError::Unavailable(reason) => (
+ "unavailable".to_owned(),
+ reason,
+ vec![
+ "radroots myc status".to_owned(),
+ "verify RADROOTS_MYC_EXECUTABLE and signer.remote_nip46 binding".to_owned(),
+ ],
+ ),
+ };
+
+ ListingMutationView {
+ state: state.clone(),
+ operation: operation.as_str().to_owned(),
+ source: LISTING_WRITE_SOURCE.to_owned(),
+ file: args.file.display().to_string(),
+ listing_id: canonical.listing_id.clone(),
+ listing_addr,
+ seller_pubkey: canonical.seller_pubkey.clone(),
+ event_kind: KIND_LISTING,
+ dry_run: false,
+ deduplicated: false,
+ job_id: None,
+ job_status: None,
+ signer_mode: Some(config.signer.backend.as_str().to_owned()),
+ signer_session_id: None,
+ event_id: None,
+ event_addr: None,
+ idempotency_key: args.idempotency_key.clone(),
+ requested_signer_session_id: args.signer_session_id.clone(),
+ reason: Some(reason),
+ job: args.print_job.then(|| ListingMutationJobView {
+ rpc_method: "bridge.listing.publish".to_owned(),
+ state,
+ job_id: None,
+ idempotency_key: args.idempotency_key.clone(),
+ requested_signer_session_id: args.signer_session_id.clone(),
+ signer_mode: Some(config.signer.backend.as_str().to_owned()),
+ signer_session_id: None,
+ }),
+ event: args.print_event.then_some(event_preview),
+ actions,
+ }
+}
+
fn issue_from_trade_validation(
error: RadrootsTradeListingValidationError,
contents: &str,
diff --git a/src/runtime/order.rs b/src/runtime/order.rs
@@ -22,6 +22,7 @@ use crate::runtime::RuntimeError;
use crate::runtime::accounts;
use crate::runtime::config::RuntimeConfig;
use crate::runtime::daemon::{self, DaemonRpcError};
+use crate::runtime::signer::{ActorWriteBindingError, validate_actor_write_binding};
const ORDER_DRAFT_KIND: &str = "order_draft_v1";
const ORDER_SOURCE: &str = "local order drafts ยท local first";
@@ -418,6 +419,12 @@ pub fn submit(
});
}
+ if let Err(error) =
+ validate_actor_write_binding(config, "buyer", loaded.document.order.buyer_pubkey.as_str())
+ {
+ return Ok(order_binding_error_view(config, &loaded, args, error));
+ }
+
let signer_session_id = match daemon::resolve_signer_session_id(
config,
"buyer",
@@ -1186,6 +1193,60 @@ fn order_submit_error_view(
}
}
+fn order_binding_error_view(
+ config: &RuntimeConfig,
+ loaded: &LoadedOrderDraft,
+ args: &OrderSubmitArgs,
+ error: ActorWriteBindingError,
+) -> OrderSubmitView {
+ let (state, reason, actions) = match error {
+ ActorWriteBindingError::Unconfigured(reason) => (
+ "unconfigured".to_owned(),
+ reason,
+ vec![
+ "radroots --signer myc signer status".to_owned(),
+ "radroots rpc sessions".to_owned(),
+ ],
+ ),
+ ActorWriteBindingError::Unavailable(reason) => (
+ "unavailable".to_owned(),
+ reason,
+ vec![
+ "radroots myc status".to_owned(),
+ "verify RADROOTS_MYC_EXECUTABLE and signer.remote_nip46 binding".to_owned(),
+ ],
+ ),
+ };
+
+ let mut actions = actions;
+ actions.push(format!(
+ "radroots order get {}",
+ loaded.document.order.order_id
+ ));
+
+ OrderSubmitView {
+ state: state.clone(),
+ source: daemon::bridge_source().to_owned(),
+ order_id: loaded.document.order.order_id.clone(),
+ file: loaded.file.display().to_string(),
+ listing_lookup: loaded.document.listing_lookup.clone(),
+ listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()),
+ buyer_account_id: loaded.document.buyer_account_id.clone(),
+ buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()),
+ seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()),
+ dry_run: false,
+ deduplicated: false,
+ idempotency_key: args.idempotency_key.clone(),
+ signer_mode: Some(config.signer.backend.as_str().to_owned()),
+ signer_session_id: None,
+ requested_signer_session_id: args.signer_session_id.clone(),
+ reason: Some(reason),
+ job: None,
+ issues: Vec::new(),
+ actions,
+ }
+}
+
fn order_watch_error_view(
loaded: &LoadedOrderDraft,
args: &OrderWatchArgs,
diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs
@@ -16,6 +16,19 @@ use radroots_nostr_signer::prelude::{
const SIGNER_BINDING_PROVIDER_RUNTIME_ID: &str = "myc";
const SIGNER_BINDING_MODEL: &str = "session_authorized_remote_signer";
+#[derive(Debug, Clone)]
+struct MycBindingResolution {
+ view: SignerBindingStatusView,
+ resolved_account_id: Option<String>,
+ resolved_signer_public_key_hex: Option<String>,
+}
+
+#[derive(Debug, Clone)]
+pub enum ActorWriteBindingError {
+ Unconfigured(String),
+ Unavailable(String),
+}
+
pub fn resolve_signer_status(config: &RuntimeConfig) -> SignerStatusView {
match config.signer.backend {
SignerBackend::Local => resolve_local_signer_status(config),
@@ -23,6 +36,50 @@ pub fn resolve_signer_status(config: &RuntimeConfig) -> SignerStatusView {
}
}
+pub fn validate_actor_write_binding(
+ config: &RuntimeConfig,
+ actor_role: &str,
+ actor_pubkey: &str,
+) -> Result<(), ActorWriteBindingError> {
+ if !matches!(config.signer.backend, SignerBackend::Myc) {
+ return Ok(());
+ }
+
+ let myc = crate::runtime::myc::resolve_status(&config.myc);
+ let resolution = resolve_myc_binding(config, &myc);
+ match resolution.view.state.as_str() {
+ "ready" => {}
+ "unavailable" => {
+ return Err(ActorWriteBindingError::Unavailable(
+ resolution.view.reason.unwrap_or_else(|| {
+ "myc signer binding is unavailable for actor-authored writes".to_owned()
+ }),
+ ));
+ }
+ _ => {
+ return Err(ActorWriteBindingError::Unconfigured(
+ resolution.view.reason.unwrap_or_else(|| {
+ "myc signer binding is not ready for actor-authored writes".to_owned()
+ }),
+ ));
+ }
+ }
+
+ let Some(resolved_signer_public_key_hex) = resolution.resolved_signer_public_key_hex else {
+ return Err(ActorWriteBindingError::Unconfigured(
+ "myc signer binding reported ready without a resolved signer identity".to_owned(),
+ ));
+ };
+
+ if !resolved_signer_public_key_hex.eq_ignore_ascii_case(actor_pubkey) {
+ return Err(ActorWriteBindingError::Unconfigured(format!(
+ "configured myc signer binding resolves signer pubkey `{resolved_signer_public_key_hex}` instead of {actor_role} pubkey `{actor_pubkey}`"
+ )));
+ }
+
+ Ok(())
+}
+
fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView {
let secret_backend = crate::runtime::accounts::secret_backend_status(config);
if secret_backend.state == "unavailable" {
@@ -138,7 +195,8 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView {
fn resolve_myc_signer_status(config: &RuntimeConfig) -> SignerStatusView {
let myc = crate::runtime::myc::resolve_status(&config.myc);
- let binding = resolve_myc_binding_status(config, &myc);
+ let resolution = resolve_myc_binding(config, &myc);
+ let binding = resolution.view;
let state = myc_signer_state(&myc, &binding).to_owned();
SignerStatusView {
mode: config.signer.backend.as_str().to_owned(),
@@ -148,7 +206,7 @@ fn resolve_myc_signer_status(config: &RuntimeConfig) -> SignerStatusView {
} else {
myc.source.clone()
},
- account_id: resolve_myc_account_id(&binding, &myc),
+ account_id: resolution.resolved_account_id,
reason: if myc.state == "ready" {
binding.reason.clone()
} else {
@@ -179,27 +237,28 @@ fn disabled_binding_status() -> SignerBindingStatusView {
}
}
-fn resolve_myc_binding_status(
- config: &RuntimeConfig,
- myc: &MycStatusView,
-) -> SignerBindingStatusView {
+fn resolve_myc_binding(config: &RuntimeConfig, myc: &MycStatusView) -> MycBindingResolution {
let Some(binding) = config.capability_binding(SIGNER_REMOTE_NIP46_CAPABILITY) else {
- return SignerBindingStatusView {
- capability_id: SIGNER_REMOTE_NIP46_CAPABILITY.to_owned(),
- provider_runtime_id: SIGNER_BINDING_PROVIDER_RUNTIME_ID.to_owned(),
- binding_model: SIGNER_BINDING_MODEL.to_owned(),
- state: "unconfigured".to_owned(),
- source: "no explicit capability binding".to_owned(),
- target_kind: None,
- target: None,
- managed_account_ref: None,
- signer_session_ref: None,
- resolved_signer_session_id: None,
- matched_session_count: None,
- reason: Some(
- "configure [[capability_binding]] for `signer.remote_nip46` before using `--signer myc`"
- .to_owned(),
- ),
+ return MycBindingResolution {
+ view: SignerBindingStatusView {
+ capability_id: SIGNER_REMOTE_NIP46_CAPABILITY.to_owned(),
+ provider_runtime_id: SIGNER_BINDING_PROVIDER_RUNTIME_ID.to_owned(),
+ binding_model: SIGNER_BINDING_MODEL.to_owned(),
+ state: "unconfigured".to_owned(),
+ source: "no explicit capability binding".to_owned(),
+ target_kind: None,
+ target: None,
+ managed_account_ref: None,
+ signer_session_ref: None,
+ resolved_signer_session_id: None,
+ matched_session_count: None,
+ reason: Some(
+ "configure [[capability_binding]] for `signer.remote_nip46` before using `--signer myc`"
+ .to_owned(),
+ ),
+ },
+ resolved_account_id: None,
+ resolved_signer_public_key_hex: None,
};
};
@@ -212,6 +271,7 @@ fn resolve_myc_binding_status(
"unsupported",
None,
None,
+ None,
format!(
"signer.remote_nip46 only supports target_kind `managed_instance`; got `{}`",
binding.target_kind.as_str()
@@ -225,6 +285,7 @@ fn resolve_myc_binding_status(
"unsupported",
None,
None,
+ None,
format!(
"managed myc target `{}` is not supported yet; use target `default`",
binding.target
@@ -240,6 +301,7 @@ fn resolve_myc_binding_status(
"unconfigured",
None,
None,
+ None,
myc.reason.clone().unwrap_or_else(|| {
"myc is not configured for composed signer bindings".to_owned()
}),
@@ -251,6 +313,7 @@ fn resolve_myc_binding_status(
"unavailable",
None,
None,
+ None,
myc.reason
.clone()
.unwrap_or_else(|| "myc is not ready for remote signer bindings".to_owned()),
@@ -275,6 +338,7 @@ fn resolve_myc_binding_status(
"unavailable",
None,
Some(0),
+ None,
format!("configured signer session `{session_ref}` is not currently available"),
);
};
@@ -285,6 +349,7 @@ fn resolve_myc_binding_status(
"unauthorized",
None,
Some(1),
+ None,
format!(
"configured signer session `{session_ref}` is not approved for `sign_event`"
),
@@ -298,6 +363,7 @@ fn resolve_myc_binding_status(
"unauthorized",
None,
Some(1),
+ None,
format!(
"configured signer session `{session_ref}` resolves signer `{}` instead of managed account `{account_ref}`",
session.signer_identity.id
@@ -311,6 +377,7 @@ fn resolve_myc_binding_status(
"ready",
Some(session.connection_id.clone()),
Some(1),
+ Some(session),
String::new(),
);
}
@@ -329,6 +396,7 @@ fn resolve_myc_binding_status(
"unavailable",
None,
Some(0),
+ None,
"no authorized remote signer session currently exposes `sign_event`".to_owned(),
);
}
@@ -339,6 +407,7 @@ fn resolve_myc_binding_status(
"ambiguous",
None,
Some(signing_sessions.len()),
+ None,
"multiple authorized remote signer sessions expose `sign_event`; set managed_account_ref or signer_session_ref".to_owned(),
);
}
@@ -352,6 +421,7 @@ fn resolve_myc_binding_status(
"ready",
Some(session.connection_id.clone()),
Some(1),
+ Some(session),
String::new(),
)
}
@@ -360,13 +430,14 @@ fn resolve_matching_sessions(
binding: &CapabilityBindingConfig,
account_ref: &str,
matching_sessions: Vec<&MycRemoteSessionView>,
-) -> SignerBindingStatusView {
+) -> MycBindingResolution {
if matching_sessions.is_empty() {
return binding_status(
binding,
"unavailable",
None,
Some(0),
+ None,
format!(
"no authorized remote signer session currently matches managed account `{account_ref}`"
),
@@ -379,6 +450,7 @@ fn resolve_matching_sessions(
"ambiguous",
None,
Some(matching_sessions.len()),
+ None,
format!(
"multiple authorized remote signer sessions currently match managed account `{account_ref}`; set signer_session_ref"
),
@@ -394,6 +466,7 @@ fn resolve_matching_sessions(
"ready",
Some(session.connection_id.clone()),
Some(1),
+ Some(session),
String::new(),
)
}
@@ -403,25 +476,31 @@ fn binding_status(
state: &str,
resolved_signer_session_id: Option<String>,
matched_session_count: Option<usize>,
+ resolved_session: Option<&MycRemoteSessionView>,
reason: String,
-) -> SignerBindingStatusView {
- SignerBindingStatusView {
- capability_id: binding.capability_id.clone(),
- provider_runtime_id: binding.provider_runtime_id.clone(),
- binding_model: binding.binding_model.clone(),
- state: state.to_owned(),
- source: binding.source.as_str().to_owned(),
- target_kind: Some(binding.target_kind.as_str().to_owned()),
- target: Some(binding.target.clone()),
- managed_account_ref: binding.managed_account_ref.clone(),
- signer_session_ref: binding.signer_session_ref.clone(),
- resolved_signer_session_id,
- matched_session_count,
- reason: if reason.is_empty() {
- None
- } else {
- Some(reason)
+) -> MycBindingResolution {
+ MycBindingResolution {
+ view: SignerBindingStatusView {
+ capability_id: binding.capability_id.clone(),
+ provider_runtime_id: binding.provider_runtime_id.clone(),
+ binding_model: binding.binding_model.clone(),
+ state: state.to_owned(),
+ source: binding.source.as_str().to_owned(),
+ target_kind: Some(binding.target_kind.as_str().to_owned()),
+ target: Some(binding.target.clone()),
+ managed_account_ref: binding.managed_account_ref.clone(),
+ signer_session_ref: binding.signer_session_ref.clone(),
+ resolved_signer_session_id,
+ matched_session_count,
+ reason: if reason.is_empty() {
+ None
+ } else {
+ Some(reason)
+ },
},
+ resolved_account_id: resolved_session.map(|session| session.signer_identity.id.clone()),
+ resolved_signer_public_key_hex: resolved_session
+ .map(|session| session.signer_identity.public_key_hex.clone()),
}
}
@@ -438,26 +517,6 @@ fn myc_signer_state(myc: &MycStatusView, binding: &SignerBindingStatusView) -> &
}
}
-fn resolve_myc_account_id(
- binding: &SignerBindingStatusView,
- myc: &MycStatusView,
-) -> Option<String> {
- if let Some(account_ref) = &binding.managed_account_ref {
- return Some(account_ref.clone());
- }
-
- binding
- .resolved_signer_session_id
- .as_deref()
- .or(binding.signer_session_ref.as_deref())
- .and_then(|session_id| {
- myc.remote_sessions
- .iter()
- .find(|session| session.connection_id == session_id)
- .map(|session| session.signer_identity.id.clone())
- })
-}
-
fn session_supports_signing(session: &MycRemoteSessionView) -> bool {
session
.permissions
diff --git a/tests/listing.rs b/tests/listing.rs
@@ -1,6 +1,7 @@
use std::fs;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
+use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -55,6 +56,56 @@ fn cli_command_in(workdir: &Path) -> Command {
command
}
+fn write_workspace_config(workdir: &Path, contents: &str) {
+ let config_dir = workdir.join(".radroots");
+ fs::create_dir_all(&config_dir).expect("workspace config dir");
+ fs::write(config_dir.join("config.toml"), contents).expect("write workspace config");
+}
+
+fn write_fake_myc(dir: &Path, script: &str) -> std::path::PathBuf {
+ let path = dir.join("fake-myc");
+ fs::write(&path, script).expect("write fake myc");
+ let mut permissions = fs::metadata(&path).expect("metadata").permissions();
+ permissions.set_mode(0o755);
+ fs::set_permissions(&path, permissions).expect("chmod fake myc");
+ path
+}
+
+fn successful_status_script(payload_json: String) -> String {
+ format!(
+ "#!/bin/sh\nif [ \"$1\" != \"status\" ] || [ \"$2\" != \"--view\" ] || [ \"$3\" != \"full\" ]; then\n echo \"unexpected args: $*\" >&2\n exit 64\nfi\ncat <<'JSON'\n{payload_json}\nJSON\n"
+ )
+}
+
+fn sample_myc_status_payload(
+ account_id: &str,
+ public_identity: &Value,
+ connection_id: &str,
+) -> Value {
+ json!({
+ "status": "healthy",
+ "ready": true,
+ "reasons": [],
+ "signer_backend": {
+ "local_signer": {
+ "account_id": account_id,
+ "public_identity": public_identity,
+ "availability": "SecretBacked"
+ },
+ "remote_session_count": 1,
+ "remote_sessions": [
+ {
+ "connection_id": connection_id,
+ "signer_identity": public_identity,
+ "user_identity": public_identity,
+ "relays": ["wss://relay.one"],
+ "permissions": "sign_event"
+ }
+ ]
+ }
+ })
+}
+
fn listing_test_guard() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
@@ -561,6 +612,271 @@ fn listing_archive_and_dry_run_are_truthful() {
}
#[test]
+fn listing_publish_uses_myc_binding_before_resolving_daemon_signer_session() {
+ let _guard = listing_test_guard();
+ let dir = tempdir().expect("tempdir");
+ let init = cli_command_in(dir.path())
+ .args(["local", "init"])
+ .output()
+ .expect("run local init");
+ assert!(init.status.success());
+
+ let account_output = cli_command_in(dir.path())
+ .args(["--json", "account", "new"])
+ .output()
+ .expect("run account new");
+ assert!(account_output.status.success());
+ let account_json: Value =
+ serde_json::from_slice(account_output.stdout.as_slice()).expect("account json");
+ let account_id = account_json["account"]["id"].as_str().expect("account id");
+ let public_identity = account_json["public_identity"].clone();
+ let seller_pubkey = public_identity["public_key_hex"]
+ .as_str()
+ .expect("seller pubkey")
+ .to_owned();
+ seed_farm(
+ dir.path(),
+ seller_pubkey.as_str(),
+ "AAAAAAAAAAAAAAAAAAAAAw",
+ "La Huerta",
+ );
+
+ let draft_path = dir.path().join("myc-listing.toml");
+ fs::write(
+ &draft_path,
+ valid_listing_draft(
+ "AAAAAAAAAAAAAAAAAAAAAg",
+ "AAAAAAAAAAAAAAAAAAAAAw",
+ seller_pubkey.as_str(),
+ "eggs",
+ "Pasture eggs",
+ "Protein",
+ "Fresh pasture-raised eggs collected daily.",
+ "12",
+ "each",
+ "4.50",
+ "USD",
+ "1",
+ "each",
+ "18",
+ "pickup",
+ "La Huerta del Sur",
+ ),
+ )
+ .expect("write listing draft");
+
+ write_workspace_config(
+ dir.path(),
+ format!(
+ r#"
+[[capability_binding]]
+capability = "signer.remote_nip46"
+provider = "myc"
+target_kind = "managed_instance"
+target = "default"
+managed_account_ref = "{account_id}"
+"#
+ )
+ .as_str(),
+ );
+ let myc = write_fake_myc(
+ dir.path(),
+ successful_status_script(
+ sample_myc_status_payload(account_id, &public_identity, "conn_listing_binding_01")
+ .to_string(),
+ )
+ .as_str(),
+ );
+
+ let requests = Arc::new(Mutex::new(Vec::<Value>::new()));
+ let recorded = Arc::clone(&requests);
+ let server = MockRpcServer::start(move |body, auth_header| {
+ recorded.lock().expect("recorded").push(body.clone());
+ match body["method"].as_str().unwrap_or_default() {
+ "nip46.session.list" => {
+ assert_eq!(auth_header, None);
+ MockRpcResponse::success(json!([sample_session(
+ "sess_publish_01",
+ seller_pubkey.as_str(),
+ &["sign_event"],
+ true
+ )]))
+ }
+ "bridge.listing.publish" => {
+ assert_eq!(auth_header.as_deref(), Some("Bearer bridge-secret"));
+ MockRpcResponse::success(json!({
+ "deduplicated": false,
+ "job": sample_listing_job(
+ "job_listing_02",
+ "published",
+ "event_listing_02",
+ "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg",
+ "sess_publish_01"
+ )
+ }))
+ }
+ other => MockRpcResponse::rpc_error(-32601, &format!("unexpected method: {other}")),
+ }
+ });
+
+ let output = cli_command_in(dir.path())
+ .env("RADROOTS_RPC_URL", server.url())
+ .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret")
+ .args([
+ "--json",
+ "--signer",
+ "myc",
+ "--myc-executable",
+ myc.to_str().expect("myc path"),
+ "listing",
+ "publish",
+ draft_path.to_str().expect("draft path"),
+ ])
+ .output()
+ .expect("run listing publish");
+
+ assert!(output.status.success());
+ let publish_json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json");
+ assert_eq!(publish_json["state"], "published");
+ assert_eq!(publish_json["signer_mode"], "nip46_session");
+ assert_eq!(publish_json["signer_session_id"], "sess_publish_01");
+ assert_eq!(publish_json["requested_signer_session_id"], Value::Null);
+
+ let recorded = requests.lock().expect("requests");
+ assert_eq!(recorded.len(), 2);
+ assert_eq!(recorded[0]["method"], "nip46.session.list");
+ assert_eq!(recorded[1]["method"], "bridge.listing.publish");
+ assert_eq!(
+ recorded[1]["params"]["signer_session_id"],
+ "sess_publish_01"
+ );
+}
+
+#[test]
+fn listing_publish_rejects_myc_binding_that_resolves_the_wrong_actor() {
+ let _guard = listing_test_guard();
+ let dir = tempdir().expect("tempdir");
+ let init = cli_command_in(dir.path())
+ .args(["local", "init"])
+ .output()
+ .expect("run local init");
+ assert!(init.status.success());
+
+ let account_output = cli_command_in(dir.path())
+ .args(["--json", "account", "new"])
+ .output()
+ .expect("run account new");
+ assert!(account_output.status.success());
+ let account_json: Value =
+ serde_json::from_slice(account_output.stdout.as_slice()).expect("account json");
+ let seller_pubkey = account_json["public_identity"]["public_key_hex"]
+ .as_str()
+ .expect("seller pubkey")
+ .to_owned();
+ seed_farm(
+ dir.path(),
+ seller_pubkey.as_str(),
+ "AAAAAAAAAAAAAAAAAAAAAw",
+ "La Huerta",
+ );
+
+ let mismatch_account_output = cli_command_in(dir.path())
+ .args(["--json", "account", "new"])
+ .output()
+ .expect("run mismatch account new");
+ assert!(mismatch_account_output.status.success());
+ let mismatch_account_json: Value =
+ serde_json::from_slice(mismatch_account_output.stdout.as_slice()).expect("mismatch json");
+ let mismatch_account_id = mismatch_account_json["account"]["id"]
+ .as_str()
+ .expect("mismatch account id");
+ let mismatch_public_identity = mismatch_account_json["public_identity"].clone();
+
+ let draft_path = dir.path().join("wrong-myc-listing.toml");
+ fs::write(
+ &draft_path,
+ valid_listing_draft(
+ "AAAAAAAAAAAAAAAAAAAAAg",
+ "AAAAAAAAAAAAAAAAAAAAAw",
+ seller_pubkey.as_str(),
+ "eggs",
+ "Pasture eggs",
+ "Protein",
+ "Fresh pasture-raised eggs collected daily.",
+ "12",
+ "each",
+ "4.50",
+ "USD",
+ "1",
+ "each",
+ "18",
+ "pickup",
+ "La Huerta del Sur",
+ ),
+ )
+ .expect("write listing draft");
+
+ write_workspace_config(
+ dir.path(),
+ format!(
+ r#"
+[[capability_binding]]
+capability = "signer.remote_nip46"
+provider = "myc"
+target_kind = "managed_instance"
+target = "default"
+managed_account_ref = "{mismatch_account_id}"
+"#
+ )
+ .as_str(),
+ );
+ let myc = write_fake_myc(
+ dir.path(),
+ successful_status_script(
+ sample_myc_status_payload(
+ mismatch_account_id,
+ &mismatch_public_identity,
+ "conn_listing_binding_02",
+ )
+ .to_string(),
+ )
+ .as_str(),
+ );
+
+ let requests = Arc::new(Mutex::new(Vec::<Value>::new()));
+ let recorded = Arc::clone(&requests);
+ let server = MockRpcServer::start(move |body, _auth_header| {
+ recorded.lock().expect("recorded").push(body.clone());
+ MockRpcResponse::rpc_error(-32601, "daemon write path should not be reached")
+ });
+
+ let output = cli_command_in(dir.path())
+ .env("RADROOTS_RPC_URL", server.url())
+ .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret")
+ .args([
+ "--json",
+ "--signer",
+ "myc",
+ "--myc-executable",
+ myc.to_str().expect("myc path"),
+ "listing",
+ "publish",
+ draft_path.to_str().expect("draft path"),
+ ])
+ .output()
+ .expect("run listing publish");
+
+ assert_eq!(output.status.code(), Some(3));
+ let publish_json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json");
+ assert_eq!(publish_json["state"], "unconfigured");
+ assert_eq!(publish_json["signer_mode"], "myc");
+ assert!(publish_json["reason"].as_str().is_some_and(|value| {
+ value.contains("configured myc signer binding resolves signer pubkey")
+ }));
+ assert!(requests.lock().expect("requests").is_empty());
+}
+
+#[test]
fn listing_publish_without_matching_signer_session_exits_unconfigured() {
let _guard = listing_test_guard();
let dir = tempdir().expect("tempdir");
diff --git a/tests/order.rs b/tests/order.rs
@@ -1,6 +1,7 @@
use std::fs;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
+use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -54,6 +55,56 @@ fn order_command_in(workdir: &Path) -> Command {
command
}
+fn write_workspace_config(workdir: &Path, contents: &str) {
+ let config_dir = workdir.join(".radroots");
+ fs::create_dir_all(&config_dir).expect("workspace config dir");
+ fs::write(config_dir.join("config.toml"), contents).expect("write workspace config");
+}
+
+fn write_fake_myc(dir: &Path, script: &str) -> std::path::PathBuf {
+ let path = dir.join("fake-myc");
+ fs::write(&path, script).expect("write fake myc");
+ let mut permissions = fs::metadata(&path).expect("metadata").permissions();
+ permissions.set_mode(0o755);
+ fs::set_permissions(&path, permissions).expect("chmod fake myc");
+ path
+}
+
+fn successful_status_script(payload_json: String) -> String {
+ format!(
+ "#!/bin/sh\nif [ \"$1\" != \"status\" ] || [ \"$2\" != \"--view\" ] || [ \"$3\" != \"full\" ]; then\n echo \"unexpected args: $*\" >&2\n exit 64\nfi\ncat <<'JSON'\n{payload_json}\nJSON\n"
+ )
+}
+
+fn sample_myc_status_payload(
+ account_id: &str,
+ public_identity: &Value,
+ connection_id: &str,
+) -> Value {
+ json!({
+ "status": "healthy",
+ "ready": true,
+ "reasons": [],
+ "signer_backend": {
+ "local_signer": {
+ "account_id": account_id,
+ "public_identity": public_identity,
+ "availability": "SecretBacked"
+ },
+ "remote_session_count": 1,
+ "remote_sessions": [
+ {
+ "connection_id": connection_id,
+ "signer_identity": public_identity,
+ "user_identity": public_identity,
+ "relays": ["wss://relay.one"],
+ "permissions": "sign_event"
+ }
+ ]
+ }
+ })
+}
+
fn order_test_guard() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
@@ -248,7 +299,7 @@ fn write_response(stream: &mut TcpStream, response: &MockRpcResponse) -> Result<
.map_err(|error| format!("flush mock rpc response: {error}"))
}
-fn sample_bridge_job(job_id: &str, state: &str, terminal: bool) -> Value {
+fn sample_bridge_job(job_id: &str, state: &str, terminal: bool, signer_session_id: &str) -> Value {
serde_json::json!({
"job_id": job_id,
"command": "bridge.order.request",
@@ -259,7 +310,7 @@ fn sample_bridge_job(job_id: &str, state: &str, terminal: bool) -> Value {
"requested_at_unix": 1_712_720_000,
"completed_at_unix": terminal.then_some(1_712_720_030),
"signer_mode": "nip46_session",
- "signer_session_id": "sess_order_01",
+ "signer_session_id": signer_session_id,
"event_kind": 30420,
"event_id": "evt_order_01",
"event_addr": "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg",
@@ -504,11 +555,14 @@ fn order_submit_persists_submission_metadata_and_reports_job() {
)])),
"bridge.order.request" => MockRpcResponse::success(serde_json::json!({
"deduplicated": false,
- "job": sample_bridge_job("job_order_01", "accepted", false),
+ "job": sample_bridge_job("job_order_01", "accepted", false, "sess_order_01"),
})),
- "bridge.job.status" => {
- MockRpcResponse::success(sample_bridge_job("job_order_01", "accepted", false))
- }
+ "bridge.job.status" => MockRpcResponse::success(sample_bridge_job(
+ "job_order_01",
+ "accepted",
+ false,
+ "sess_order_01",
+ )),
other => panic!("unexpected mock rpc method {other}"),
}
});
@@ -584,9 +638,19 @@ fn order_watch_reports_job_frames_for_submitted_order() {
let mut count = watch_polls.lock().expect("watch polls lock");
*count += 1;
if *count == 1 {
- MockRpcResponse::success(sample_bridge_job("job_watch_01", "accepted", false))
+ MockRpcResponse::success(sample_bridge_job(
+ "job_watch_01",
+ "accepted",
+ false,
+ "sess_order_01",
+ ))
} else {
- MockRpcResponse::success(sample_bridge_job("job_watch_01", "completed", true))
+ MockRpcResponse::success(sample_bridge_job(
+ "job_watch_01",
+ "completed",
+ true,
+ "sess_order_01",
+ ))
}
}
other => panic!("unexpected mock rpc method {other}"),
@@ -646,6 +710,242 @@ job_id = "job_watch_01"
}
#[test]
+fn order_submit_uses_myc_binding_before_resolving_daemon_signer_session() {
+ let _guard = order_test_guard();
+ let dir = tempdir().expect("tempdir");
+
+ let account_output = order_command_in(dir.path())
+ .args(["--json", "account", "new"])
+ .output()
+ .expect("run account new");
+ assert!(account_output.status.success());
+ let account_json: Value =
+ serde_json::from_slice(account_output.stdout.as_slice()).expect("account json");
+ let account_id = account_json["account"]["id"].as_str().expect("account id");
+ let public_identity = account_json["public_identity"].clone();
+ let buyer_pubkey = public_identity["public_key_hex"]
+ .as_str()
+ .expect("buyer pubkey")
+ .to_owned();
+
+ write_workspace_config(
+ dir.path(),
+ format!(
+ r#"
+[[capability_binding]]
+capability = "signer.remote_nip46"
+provider = "myc"
+target_kind = "managed_instance"
+target = "default"
+managed_account_ref = "{account_id}"
+"#
+ )
+ .as_str(),
+ );
+ let myc = write_fake_myc(
+ dir.path(),
+ successful_status_script(
+ sample_myc_status_payload(account_id, &public_identity, "conn_order_binding_01")
+ .to_string(),
+ )
+ .as_str(),
+ );
+
+ let new_output = order_command_in(dir.path())
+ .args([
+ "--json",
+ "order",
+ "new",
+ "--listing",
+ "pasture-eggs",
+ "--listing-addr",
+ "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg",
+ "--bin",
+ "bin-1",
+ "--qty",
+ "2",
+ ])
+ .output()
+ .expect("run order new");
+ assert!(new_output.status.success());
+ let new_json: Value = serde_json::from_slice(new_output.stdout.as_slice()).expect("new json");
+ let order_id = new_json["order_id"].as_str().expect("order id");
+
+ let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new()));
+ let recorded = Arc::clone(&requests);
+ let server = MockRpcServer::start(move |body, auth_header| {
+ recorded
+ .lock()
+ .expect("recorded requests lock")
+ .push(MockRpcRequest {
+ body: body.clone(),
+ method: body["method"].as_str().unwrap_or_default().to_owned(),
+ auth_header,
+ });
+ match body["method"].as_str().unwrap_or_default() {
+ "nip46.session.list" => MockRpcResponse::success(json!([sample_session(
+ "sess_order_02",
+ buyer_pubkey.as_str(),
+ &["sign_event"],
+ true
+ )])),
+ "bridge.order.request" => MockRpcResponse::success(serde_json::json!({
+ "deduplicated": false,
+ "job": sample_bridge_job("job_order_02", "accepted", false, "sess_order_02"),
+ })),
+ other => panic!("unexpected mock rpc method {other}"),
+ }
+ });
+
+ let submit_output = order_command_in(dir.path())
+ .env("RADROOTS_RPC_URL", server.url())
+ .env("RADROOTS_RPC_BEARER_TOKEN", "test-token")
+ .args([
+ "--json",
+ "--signer",
+ "myc",
+ "--myc-executable",
+ myc.to_str().expect("myc path"),
+ "order",
+ "submit",
+ order_id,
+ ])
+ .output()
+ .expect("run order submit");
+
+ assert!(submit_output.status.success());
+ let submit_json: Value =
+ serde_json::from_slice(submit_output.stdout.as_slice()).expect("submit json");
+ assert_eq!(submit_json["state"], "accepted");
+ assert_eq!(submit_json["signer_mode"], "nip46_session");
+ assert_eq!(submit_json["signer_session_id"], "sess_order_02");
+ assert_eq!(submit_json["requested_signer_session_id"], Value::Null);
+
+ let recorded_requests = requests.lock().expect("requests lock");
+ assert!(
+ recorded_requests
+ .iter()
+ .any(|request| request.method == "nip46.session.list")
+ );
+ let request = recorded_requests
+ .iter()
+ .find(|request| request.method == "bridge.order.request")
+ .expect("bridge order request");
+ assert_eq!(request.body["params"]["signer_session_id"], "sess_order_02");
+}
+
+#[test]
+fn order_submit_rejects_myc_binding_that_resolves_the_wrong_actor() {
+ let _guard = order_test_guard();
+ let dir = tempdir().expect("tempdir");
+
+ let account_output = order_command_in(dir.path())
+ .args(["--json", "account", "new"])
+ .output()
+ .expect("run account new");
+ assert!(account_output.status.success());
+
+ let new_output = order_command_in(dir.path())
+ .args([
+ "--json",
+ "order",
+ "new",
+ "--listing",
+ "pasture-eggs",
+ "--listing-addr",
+ "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg",
+ "--bin",
+ "bin-1",
+ "--qty",
+ "2",
+ ])
+ .output()
+ .expect("run order new");
+ assert!(new_output.status.success());
+ let new_json: Value = serde_json::from_slice(new_output.stdout.as_slice()).expect("new json");
+ let order_id = new_json["order_id"].as_str().expect("order id");
+
+ let mismatch_account_output = order_command_in(dir.path())
+ .args(["--json", "account", "new"])
+ .output()
+ .expect("run mismatch account new");
+ assert!(mismatch_account_output.status.success());
+ let mismatch_account_json: Value =
+ serde_json::from_slice(mismatch_account_output.stdout.as_slice()).expect("mismatch json");
+ let mismatch_account_id = mismatch_account_json["account"]["id"]
+ .as_str()
+ .expect("mismatch account id");
+ let mismatch_public_identity = mismatch_account_json["public_identity"].clone();
+
+ write_workspace_config(
+ dir.path(),
+ format!(
+ r#"
+[[capability_binding]]
+capability = "signer.remote_nip46"
+provider = "myc"
+target_kind = "managed_instance"
+target = "default"
+managed_account_ref = "{mismatch_account_id}"
+"#
+ )
+ .as_str(),
+ );
+ let myc = write_fake_myc(
+ dir.path(),
+ successful_status_script(
+ sample_myc_status_payload(
+ mismatch_account_id,
+ &mismatch_public_identity,
+ "conn_order_binding_02",
+ )
+ .to_string(),
+ )
+ .as_str(),
+ );
+
+ let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new()));
+ let recorded = Arc::clone(&requests);
+ let server = MockRpcServer::start(move |body, auth_header| {
+ recorded
+ .lock()
+ .expect("recorded requests lock")
+ .push(MockRpcRequest {
+ body,
+ method: "unexpected".to_owned(),
+ auth_header,
+ });
+ panic!("daemon write path should not be reached");
+ });
+
+ let submit_output = order_command_in(dir.path())
+ .env("RADROOTS_RPC_URL", server.url())
+ .env("RADROOTS_RPC_BEARER_TOKEN", "test-token")
+ .args([
+ "--json",
+ "--signer",
+ "myc",
+ "--myc-executable",
+ myc.to_str().expect("myc path"),
+ "order",
+ "submit",
+ order_id,
+ ])
+ .output()
+ .expect("run order submit");
+
+ assert_eq!(submit_output.status.code(), Some(3));
+ let submit_json: Value =
+ serde_json::from_slice(submit_output.stdout.as_slice()).expect("submit json");
+ assert_eq!(submit_json["state"], "unconfigured");
+ assert_eq!(submit_json["signer_mode"], "myc");
+ assert!(submit_json["reason"].as_str().is_some_and(|value| {
+ value.contains("configured myc signer binding resolves signer pubkey")
+ }));
+ assert!(requests.lock().expect("requests lock").is_empty());
+}
+
+#[test]
fn order_submit_without_unique_matching_signer_session_exits_unconfigured() {
let _guard = order_test_guard();
let dir = tempdir().expect("tempdir");