commit 4e6b4cb2328e14e63e0feb223ef96543e89a22db
parent 10823fc93b339d601858b416881e3d063c6d6874
Author: triesap <tyson@radroots.org>
Date: Wed, 8 Apr 2026 18:45:27 +0000
cli: resolve signer sessions for daemon writes
Diffstat:
5 files changed, 638 insertions(+), 56 deletions(-)
diff --git a/src/runtime/daemon.rs b/src/runtime/daemon.rs
@@ -374,6 +374,7 @@ pub fn bridge_listing_publish(
listing: &RadrootsListing,
kind: u32,
idempotency_key: Option<&str>,
+ signer_session_id: Option<&str>,
) -> Result<BridgeListingPublishResult, DaemonRpcError> {
let response: BridgePublishResponseRemote = call(
config,
@@ -382,6 +383,7 @@ pub fn bridge_listing_publish(
"listing": listing,
"kind": kind,
"idempotency_key": idempotency_key,
+ "signer_session_id": signer_session_id,
})),
RpcAuthMode::BridgeBearer,
)?;
@@ -401,6 +403,7 @@ pub fn bridge_order_request(
config: &RuntimeConfig,
order: &RadrootsTradeOrder,
idempotency_key: Option<&str>,
+ signer_session_id: Option<&str>,
) -> Result<BridgeOrderRequestResult, DaemonRpcError> {
let response: BridgePublishResponseRemote = call(
config,
@@ -408,6 +411,7 @@ pub fn bridge_order_request(
Some(serde_json::json!({
"order": order,
"idempotency_key": idempotency_key,
+ "signer_session_id": signer_session_id,
})),
RpcAuthMode::BridgeBearer,
)?;
@@ -446,6 +450,89 @@ fn nip46_sessions(config: &RuntimeConfig) -> Result<Vec<Nip46SessionRemote>, Dae
call(config, "nip46.session.list", None, RpcAuthMode::None)
}
+pub fn resolve_signer_session_id(
+ config: &RuntimeConfig,
+ actor_role: &str,
+ actor_pubkey: &str,
+ event_kind: u32,
+ requested_session_id: Option<&str>,
+) -> Result<String, DaemonRpcError> {
+ let sessions = nip46_sessions(config)?;
+
+ if let Some(session_id) = requested_session_id {
+ let Some(session) = sessions
+ .into_iter()
+ .find(|session| session.session_id == session_id)
+ else {
+ return Err(DaemonRpcError::Unconfigured(format!(
+ "requested signer session `{session_id}` was not found"
+ )));
+ };
+ validate_signer_session(&session, actor_role, actor_pubkey, event_kind)?;
+ return Ok(session.session_id);
+ }
+
+ let mut matches = sessions
+ .into_iter()
+ .filter(|session| session_matches_actor(session, actor_pubkey, event_kind))
+ .map(|session| session.session_id)
+ .collect::<Vec<_>>();
+
+ match matches.len() {
+ 1 => Ok(matches.pop().expect("exactly one signer session")),
+ 0 => Err(DaemonRpcError::Unconfigured(format!(
+ "no authorized signer session matched {actor_role} pubkey `{actor_pubkey}` for sign_event:{event_kind}; connect a signer session or pass --signer-session-id"
+ ))),
+ _ => Err(DaemonRpcError::Unconfigured(format!(
+ "multiple authorized signer sessions matched {actor_role} pubkey `{actor_pubkey}` for sign_event:{event_kind}; pass --signer-session-id"
+ ))),
+ }
+}
+
+fn validate_signer_session(
+ session: &Nip46SessionRemote,
+ actor_role: &str,
+ actor_pubkey: &str,
+ event_kind: u32,
+) -> Result<(), DaemonRpcError> {
+ if !session.authorized {
+ return Err(DaemonRpcError::Unconfigured(format!(
+ "requested signer session `{}` is not authorized",
+ session.session_id
+ )));
+ }
+ if !session.signer_pubkey.eq_ignore_ascii_case(actor_pubkey) {
+ return Err(DaemonRpcError::Unconfigured(format!(
+ "requested signer session `{}` signer pubkey `{}` does not match {actor_role} pubkey `{actor_pubkey}`",
+ session.session_id, session.signer_pubkey
+ )));
+ }
+ if !sign_event_allowed(&session.permissions, event_kind) {
+ return Err(DaemonRpcError::Unconfigured(format!(
+ "requested signer session `{}` is not approved for sign_event:{event_kind}",
+ session.session_id
+ )));
+ }
+ Ok(())
+}
+
+fn session_matches_actor(
+ session: &Nip46SessionRemote,
+ actor_pubkey: &str,
+ event_kind: u32,
+) -> bool {
+ session.authorized
+ && session.signer_pubkey.eq_ignore_ascii_case(actor_pubkey)
+ && sign_event_allowed(&session.permissions, event_kind)
+}
+
+fn sign_event_allowed(perms: &[String], kind: u32) -> bool {
+ perms.iter().any(|entry| entry == "sign_event")
+ || perms
+ .iter()
+ .any(|entry| entry == &format!("sign_event:{kind}"))
+}
+
fn call<T: DeserializeOwned>(
config: &RuntimeConfig,
method: &str,
diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs
@@ -541,11 +541,33 @@ fn mutate(
});
}
+ let signer_session_id = match daemon::resolve_signer_session_id(
+ config,
+ "seller",
+ canonical.seller_pubkey.as_str(),
+ KIND_LISTING,
+ args.signer_session_id.as_deref(),
+ ) {
+ Ok(session_id) => session_id,
+ Err(error) => {
+ return Ok(daemon_error_view(
+ config,
+ args,
+ operation,
+ &canonical,
+ listing_addr,
+ event_preview,
+ error,
+ ));
+ }
+ };
+
match daemon::bridge_listing_publish(
config,
&canonical.listing,
KIND_LISTING,
args.idempotency_key.as_deref(),
+ Some(signer_session_id.as_str()),
) {
Ok(result) => {
let failed = result.status == "failed";
diff --git a/src/runtime/order.rs b/src/runtime/order.rs
@@ -5,7 +5,9 @@ use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use radroots_events::kinds::KIND_LISTING;
-use radroots_events::trade::{RadrootsTradeOrder, RadrootsTradeOrderItem};
+use radroots_events::trade::{
+ RadrootsTradeMessageType, RadrootsTradeOrder, RadrootsTradeOrderItem,
+};
use radroots_events_codec::d_tag::is_d_tag_base64url;
use radroots_events_codec::trade::RadrootsTradeListingAddress;
use serde::{Deserialize, Serialize};
@@ -402,8 +404,24 @@ pub fn submit(
});
}
+ let signer_session_id = match daemon::resolve_signer_session_id(
+ config,
+ "buyer",
+ loaded.document.order.buyer_pubkey.as_str(),
+ u32::from(RadrootsTradeMessageType::OrderRequest.kind()),
+ args.signer_session_id.as_deref(),
+ ) {
+ Ok(session_id) => session_id,
+ Err(error) => return Ok(order_submit_error_view(&loaded, args, error)),
+ };
+
let order = trade_order_from_document(&loaded.document);
- match daemon::bridge_order_request(config, &order, args.idempotency_key.as_deref()) {
+ match daemon::bridge_order_request(
+ config,
+ &order,
+ args.idempotency_key.as_deref(),
+ Some(signer_session_id.as_str()),
+ ) {
Ok(result) => {
let mut updated = loaded.document.clone();
updated.submission = Some(OrderDraftSubmission {
diff --git a/tests/listing.rs b/tests/listing.rs
@@ -120,9 +120,10 @@ fn listing_validate_resolves_selected_account_and_matching_farm() {
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");
+ .expect("seller pubkey")
+ .to_owned();
let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw";
- seed_farm(dir.path(), seller_pubkey, farm_d_tag, "La Huerta");
+ seed_farm(dir.path(), seller_pubkey.as_str(), farm_d_tag, "La Huerta");
let draft_path = dir.path().join("eggs.toml");
fs::write(
@@ -283,9 +284,10 @@ fn listing_publish_and_update_use_durable_bridge_publish() {
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");
+ .expect("seller pubkey")
+ .to_owned();
let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw";
- seed_farm(dir.path(), seller_pubkey, farm_d_tag, "La Huerta");
+ seed_farm(dir.path(), seller_pubkey.as_str(), farm_d_tag, "La Huerta");
let draft_path = dir.path().join("eggs.toml");
fs::write(
@@ -315,12 +317,23 @@ fn listing_publish_and_update_use_durable_bridge_publish() {
let recorded = Arc::clone(&requests);
let server = MockRpcServer::start(move |body, auth_header| {
recorded.lock().expect("recorded").push(body.clone());
- assert_eq!(auth_header.as_deref(), Some("Bearer bridge-secret"));
match body["method"].as_str().unwrap_or_default() {
- "bridge.listing.publish" => MockRpcResponse::success(json!({
- "deduplicated": false,
- "job": sample_listing_job("job_listing_01", "published", "event_listing_01", "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg")
- })),
+ "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_01", "published", "event_listing_01", "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg")
+ }))
+ }
other => MockRpcResponse::rpc_error(-32601, &format!("unexpected method: {other}")),
}
});
@@ -377,10 +390,20 @@ fn listing_publish_and_update_use_durable_bridge_publish() {
assert_eq!(update_json["operation"], "update");
let recorded = requests.lock().expect("requests");
- assert_eq!(recorded.len(), 2);
- assert_eq!(recorded[0]["params"]["kind"], 30402);
- assert_eq!(recorded[0]["params"]["idempotency_key"], "publish-key");
+ assert_eq!(recorded.len(), 4);
+ assert_eq!(recorded[0]["method"], "nip46.session.list");
assert_eq!(recorded[1]["params"]["kind"], 30402);
+ assert_eq!(recorded[1]["params"]["idempotency_key"], "publish-key");
+ assert_eq!(
+ recorded[1]["params"]["signer_session_id"],
+ "sess_publish_01"
+ );
+ assert_eq!(recorded[2]["method"], "nip46.session.list");
+ assert_eq!(recorded[3]["params"]["kind"], 30402);
+ assert_eq!(
+ recorded[3]["params"]["signer_session_id"],
+ "sess_publish_01"
+ );
}
#[test]
@@ -402,10 +425,11 @@ fn listing_archive_and_dry_run_are_truthful() {
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");
+ .expect("seller pubkey")
+ .to_owned();
seed_farm(
dir.path(),
- seller_pubkey,
+ seller_pubkey.as_str(),
"AAAAAAAAAAAAAAAAAAAAAw",
"La Huerta",
);
@@ -438,10 +462,19 @@ fn listing_archive_and_dry_run_are_truthful() {
let recorded = Arc::clone(&requests);
let server = MockRpcServer::start(move |body, _auth_header| {
recorded.lock().expect("recorded").push(body.to_string());
- MockRpcResponse::success(json!({
- "deduplicated": false,
- "job": sample_listing_job("job_listing_archive", "published", "event_listing_archive", "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg")
- }))
+ match body["method"].as_str().unwrap_or_default() {
+ "nip46.session.list" => MockRpcResponse::success(json!([sample_session(
+ "sess_archive_01",
+ seller_pubkey.as_str(),
+ &["sign_event"],
+ true
+ )])),
+ "bridge.listing.publish" => MockRpcResponse::success(json!({
+ "deduplicated": false,
+ "job": sample_listing_job("job_listing_archive", "published", "event_listing_archive", "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg")
+ })),
+ other => MockRpcResponse::rpc_error(-32601, &format!("unexpected method: {other}")),
+ }
});
let archive_output = cli_command_in(dir.path())
@@ -499,8 +532,198 @@ fn listing_archive_and_dry_run_are_truthful() {
);
let recorded = requests.lock().expect("requests");
+ assert_eq!(recorded.len(), 2);
+ assert!(recorded[1].contains("archived"));
+}
+
+#[test]
+fn listing_publish_without_matching_signer_session_exits_unconfigured() {
+ 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 draft_path = dir.path().join("no-session.toml");
+ fs::write(
+ &draft_path,
+ valid_listing_draft(
+ "AAAAAAAAAAAAAAAAAAAAAg",
+ "",
+ "",
+ "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");
+
+ 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" => MockRpcResponse::success(json!([sample_session(
+ "sess_other_01",
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ &["sign_event"],
+ true
+ )])),
+ other => MockRpcResponse::rpc_error(-32601, &format!("unexpected method: {other}")),
+ }
+ });
+
+ let publish_output = cli_command_in(dir.path())
+ .env("RADROOTS_RPC_URL", server.url())
+ .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret")
+ .args([
+ "--json",
+ "listing",
+ "publish",
+ draft_path.to_str().expect("draft path"),
+ ])
+ .output()
+ .expect("run listing publish");
+ assert_eq!(publish_output.status.code(), Some(3));
+ let publish_json: Value =
+ serde_json::from_slice(publish_output.stdout.as_slice()).expect("publish json");
+ assert_eq!(publish_json["state"], "unconfigured");
+ assert!(
+ publish_json["reason"]
+ .as_str()
+ .expect("reason")
+ .contains("no authorized signer session matched seller pubkey")
+ );
+
+ let recorded = requests.lock().expect("requests");
assert_eq!(recorded.len(), 1);
- assert!(recorded[0].contains("archived"));
+ assert_eq!(recorded[0]["method"], "nip46.session.list");
+}
+
+#[test]
+fn listing_publish_rejects_requested_session_that_mismatches_seller_pubkey() {
+ 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 draft_path = dir.path().join("mismatch-session.toml");
+ fs::write(
+ &draft_path,
+ valid_listing_draft(
+ "AAAAAAAAAAAAAAAAAAAAAg",
+ "",
+ "",
+ "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");
+
+ 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" => MockRpcResponse::success(json!([sample_session(
+ "sess_wrong_01",
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ &["sign_event"],
+ true
+ )])),
+ other => MockRpcResponse::rpc_error(-32601, &format!("unexpected method: {other}")),
+ }
+ });
+
+ let publish_output = cli_command_in(dir.path())
+ .env("RADROOTS_RPC_URL", server.url())
+ .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret")
+ .args([
+ "--json",
+ "listing",
+ "publish",
+ "--signer-session-id",
+ "sess_wrong_01",
+ draft_path.to_str().expect("draft path"),
+ ])
+ .output()
+ .expect("run listing publish");
+ assert_eq!(publish_output.status.code(), Some(3));
+ let publish_json: Value =
+ serde_json::from_slice(publish_output.stdout.as_slice()).expect("publish json");
+ assert_eq!(publish_json["state"], "unconfigured");
+ assert!(
+ publish_json["reason"]
+ .as_str()
+ .expect("reason")
+ .contains("does not match seller pubkey")
+ );
+
+ let recorded = requests.lock().expect("requests");
+ assert_eq!(recorded.len(), 1);
+ assert_eq!(recorded[0]["method"], "nip46.session.list");
}
fn seed_farm(workdir: &Path, pubkey: &str, d_tag: &str, name: &str) {
@@ -747,6 +970,26 @@ fn sample_listing_job(job_id: &str, status: &str, event_id: &str, event_addr: &s
})
}
+fn sample_session(
+ session_id: &str,
+ signer_pubkey: &str,
+ permissions: &[&str],
+ authorized: bool,
+) -> Value {
+ json!({
+ "session_id": session_id,
+ "role": "remote_signer",
+ "client_pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "signer_pubkey": signer_pubkey,
+ "user_pubkey": Value::Null,
+ "relays": ["wss://relay.one"],
+ "permissions": permissions,
+ "auth_required": false,
+ "authorized": authorized,
+ "expires_in_secs": Value::Null
+ })
+}
+
fn seed_trade_product(
workdir: &Path,
product_id: &str,
diff --git a/tests/order.rs b/tests/order.rs
@@ -9,7 +9,7 @@ use std::thread::{self, JoinHandle};
use std::time::Duration;
use assert_cmd::prelude::*;
-use serde_json::Value;
+use serde_json::{Value, json};
use tempfile::tempdir;
fn data_root(workdir: &Path) -> std::path::PathBuf {
@@ -61,6 +61,7 @@ fn order_test_guard() -> MutexGuard<'static, ()> {
#[derive(Debug, Clone)]
struct MockRpcRequest {
+ body: Value,
method: String,
auth_header: Option<String>,
}
@@ -91,7 +92,7 @@ struct MockRpcServer {
impl MockRpcServer {
fn start<F>(handler: F) -> Self
where
- F: Fn(String, Option<String>) -> MockRpcResponse + Send + Sync + 'static,
+ F: Fn(Value, Option<String>) -> MockRpcResponse + Send + Sync + 'static,
{
let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock rpc listener");
listener
@@ -103,7 +104,7 @@ impl MockRpcServer {
.to_string();
let shutdown = Arc::new(AtomicBool::new(false));
let shutdown_flag = Arc::clone(&shutdown);
- let handler: Arc<dyn Fn(String, Option<String>) -> MockRpcResponse + Send + Sync> =
+ let handler: Arc<dyn Fn(Value, Option<String>) -> MockRpcResponse + Send + Sync> =
Arc::new(handler);
let handle = thread::spawn(move || {
while !shutdown_flag.load(Ordering::SeqCst) {
@@ -111,7 +112,7 @@ impl MockRpcServer {
Ok((mut stream, _)) => {
if let Ok(request) = read_request(&mut stream) {
let response =
- handler(request.method.clone(), request.auth_header.clone());
+ handler(request.body.clone(), request.auth_header.clone());
let _ = write_response(&mut stream, &response);
}
}
@@ -192,6 +193,7 @@ fn read_request(stream: &mut TcpStream) -> Result<MockRpcRequest, String> {
.to_owned();
Ok(MockRpcRequest {
+ body: envelope,
method,
auth_header,
})
@@ -450,28 +452,6 @@ job_id = "job_order_01"
fn order_submit_persists_submission_metadata_and_reports_job() {
let _guard = order_test_guard();
let dir = tempdir().expect("tempdir");
- let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new()));
- let recorded = Arc::clone(&requests);
- let server = MockRpcServer::start(move |method, auth_header| {
- recorded
- .lock()
- .expect("recorded requests lock")
- .push(MockRpcRequest {
- method: method.clone(),
- auth_header,
- });
- match method.as_str() {
- "bridge.order.request" => MockRpcResponse::success(serde_json::json!({
- "deduplicated": false,
- "job": sample_bridge_job("job_order_01", "accepted", false),
- })),
- "bridge.job.status" => {
- MockRpcResponse::success(sample_bridge_job("job_order_01", "accepted", false))
- }
- other => panic!("unexpected mock rpc method {other}"),
- }
- });
-
let account_output = order_command_in(dir.path())
.args(["--json", "account", "new"])
.output()
@@ -496,6 +476,39 @@ fn order_submit_persists_submission_metadata_and_reports_job() {
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 file = new_json["file"].as_str().expect("file");
+ let buyer_pubkey = new_json["buyer_pubkey"]
+ .as_str()
+ .expect("buyer pubkey")
+ .to_owned();
+
+ 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_01",
+ buyer_pubkey.as_str(),
+ &["sign_event"],
+ true
+ )])),
+ "bridge.order.request" => MockRpcResponse::success(serde_json::json!({
+ "deduplicated": false,
+ "job": sample_bridge_job("job_order_01", "accepted", false),
+ })),
+ "bridge.job.status" => {
+ MockRpcResponse::success(sample_bridge_job("job_order_01", "accepted", false))
+ }
+ other => panic!("unexpected mock rpc method {other}"),
+ }
+ });
let submit_output = order_command_in(dir.path())
.env("RADROOTS_RPC_URL", server.url())
@@ -538,6 +551,16 @@ fn order_submit_persists_submission_metadata_and_reports_job() {
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_01");
+ assert!(
+ recorded_requests
+ .iter()
.any(|request| { request.auth_header.as_deref() == Some("Bearer test-token") })
);
}
@@ -548,17 +571,19 @@ fn order_watch_reports_job_frames_for_submitted_order() {
let dir = tempdir().expect("tempdir");
let polls = Arc::new(Mutex::new(0usize));
let watch_polls = Arc::clone(&polls);
- let server = MockRpcServer::start(move |method, _auth_header| match method.as_str() {
- "bridge.job.status" => {
- 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))
- } else {
- MockRpcResponse::success(sample_bridge_job("job_watch_01", "completed", true))
+ let server = MockRpcServer::start(move |body, _auth_header| {
+ match body["method"].as_str().unwrap_or_default() {
+ "bridge.job.status" => {
+ 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))
+ } else {
+ MockRpcResponse::success(sample_bridge_job("job_watch_01", "completed", true))
+ }
}
+ other => panic!("unexpected mock rpc method {other}"),
}
- other => panic!("unexpected mock rpc method {other}"),
});
let drafts_dir = data_root(dir.path()).join("apps/cli/orders/drafts");
@@ -610,6 +635,193 @@ job_id = "job_watch_01"
}
#[test]
+fn order_submit_without_unique_matching_signer_session_exits_unconfigured() {
+ 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 buyer_pubkey = account_json["public_identity"]["public_key_hex"]
+ .as_str()
+ .expect("buyer pubkey")
+ .to_owned();
+
+ let new_output = order_command_in(dir.path())
+ .args([
+ "--json",
+ "order",
+ "new",
+ "--listing",
+ "pasture-eggs",
+ "--listing-addr",
+ "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg",
+ "--bin",
+ "bin-1",
+ ])
+ .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: None,
+ });
+ match body["method"].as_str().unwrap_or_default() {
+ "nip46.session.list" => MockRpcResponse::success(json!([
+ sample_session(
+ "sess_order_01",
+ buyer_pubkey.as_str(),
+ &["sign_event"],
+ true
+ ),
+ sample_session(
+ "sess_order_02",
+ buyer_pubkey.as_str(),
+ &["sign_event"],
+ true
+ )
+ ])),
+ 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", "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!(
+ submit_json["reason"]
+ .as_str()
+ .expect("reason")
+ .contains("multiple authorized signer sessions matched buyer pubkey")
+ );
+
+ let recorded_requests = requests.lock().expect("requests lock");
+ assert_eq!(recorded_requests.len(), 1);
+ assert_eq!(recorded_requests[0].method, "nip46.session.list");
+}
+
+#[test]
+fn order_submit_rejects_requested_session_that_mismatches_buyer_pubkey() {
+ 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",
+ ])
+ .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: None,
+ });
+ match body["method"].as_str().unwrap_or_default() {
+ "nip46.session.list" => MockRpcResponse::success(json!([sample_session(
+ "sess_wrong_01",
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ &["sign_event"],
+ true
+ )])),
+ 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",
+ "order",
+ "submit",
+ order_id,
+ "--signer-session-id",
+ "sess_wrong_01",
+ ])
+ .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!(
+ submit_json["reason"]
+ .as_str()
+ .expect("reason")
+ .contains("does not match buyer pubkey")
+ );
+
+ let recorded_requests = requests.lock().expect("requests lock");
+ assert_eq!(recorded_requests.len(), 1);
+ assert_eq!(recorded_requests[0].method, "nip46.session.list");
+}
+
+fn sample_session(
+ session_id: &str,
+ signer_pubkey: &str,
+ permissions: &[&str],
+ authorized: bool,
+) -> Value {
+ json!({
+ "session_id": session_id,
+ "role": "remote_signer",
+ "client_pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "signer_pubkey": signer_pubkey,
+ "user_pubkey": Value::Null,
+ "relays": ["wss://relay.one"],
+ "permissions": permissions,
+ "auth_required": false,
+ "authorized": authorized,
+ "expires_in_secs": Value::Null
+ })
+}
+
+#[test]
fn order_history_lists_submitted_order_drafts() {
let _guard = order_test_guard();
let dir = tempdir().expect("tempdir");