cli

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

commit 4210455995ea516c84cd6788851b1ad8830a7be1
parent 255f5d50707f732590b1813efa247e0a9e896588
Author: triesap <tyson@radroots.org>
Date:   Sat, 25 Apr 2026 09:49:55 +0000

signer: add session lifecycle commands

Diffstat:
Msrc/cli.rs | 70+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/commands/mod.rs | 36+++++++++++++++++++++++++++++++++++-
Msrc/commands/signer.rs | 143++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/domain/runtime.rs | 42++++++++++++++++++++++++++++++++++++++++++
Msrc/render/mod.rs | 90++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/runtime/daemon.rs | 272++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/runtime/signer.rs | 21+++++++++++++++++++++
Mtests/job_rpc.rs | 352++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mtests/listing.rs | 5++++-
Mtests/order.rs | 5++++-
10 files changed, 972 insertions(+), 64 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -535,6 +535,7 @@ impl Command { }, Self::Signer(signer) => match signer.command { SignerCommand::Status => "signer status", + SignerCommand::Session(_) => "signer session", }, Self::Status => "status", Self::Sync(sync) => match sync.command { @@ -698,6 +699,43 @@ pub struct SignerArgs { #[derive(Debug, Clone, Subcommand)] pub enum SignerCommand { Status, + Session(SignerSessionArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct SignerSessionArgs { + #[command(subcommand)] + pub command: SignerSessionCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum SignerSessionCommand { + List, + Show { + session_id: String, + }, + ConnectBunker { + url: String, + }, + ConnectNostrconnect { + url: String, + #[arg(long)] + client_secret_key: String, + }, + PublicKey { + session_id: String, + }, + Authorize { + session_id: String, + }, + RequireAuth { + session_id: String, + #[arg(long)] + auth_url: String, + }, + Close { + session_id: String, + }, } #[derive(Debug, Clone, Args)] @@ -1232,7 +1270,7 @@ mod tests { JobCommand, JobWatchArgs, ListingCommand, LocalCommand, LocalExportFormatArg, MarketCommand, MycCommand, NetCommand, OrderCommand, OrderWatchArgs, OutputFormatArg, RelayCommand, RpcCommand, RuntimeCommand, RuntimeConfigCommand, SellCommand, SetupRoleArg, - SignerCommand, SyncCommand, SyncWatchArgs, + SignerCommand, SignerSessionCommand, SyncCommand, SyncWatchArgs, }; use crate::runtime::config::OutputFormat; #[test] @@ -1616,6 +1654,36 @@ mod tests { match parsed.command { Command::Signer(signer) => match signer.command { SignerCommand::Status => {} + SignerCommand::Session(_) => panic!("unexpected signer session command"), + }, + _ => panic!("unexpected command variant"), + } + } + + #[test] + fn parses_signer_session_lifecycle_commands() { + let parsed = CliArgs::parse_from([ + "radroots", + "signer", + "session", + "require-auth", + "sess_123", + "--auth-url", + "https://auth.example", + ]); + match parsed.command { + Command::Signer(signer) => match signer.command { + SignerCommand::Session(session) => match session.command { + SignerSessionCommand::RequireAuth { + session_id, + auth_url, + } => { + assert_eq!(session_id, "sess_123"); + assert_eq!(auth_url, "https://auth.example"); + } + _ => panic!("unexpected signer session subcommand"), + }, + SignerCommand::Status => panic!("unexpected signer status command"), }, _ => panic!("unexpected command variant"), } diff --git a/src/commands/mod.rs b/src/commands/mod.rs @@ -20,7 +20,7 @@ pub mod workflow; use crate::cli::{ AccountCommand, Command, ConfigCommand, FarmCommand, JobCommand, ListingCommand, LocalCommand, MarketCommand, MycCommand, NetCommand, OrderCommand, RelayCommand, RpcCommand, RuntimeCommand, - RuntimeConfigCommand, SellCommand, SignerCommand, SyncCommand, + RuntimeConfigCommand, SellCommand, SignerCommand, SignerSessionCommand, SyncCommand, }; use crate::domain::runtime::{CommandOutput, CommandView}; use crate::runtime::RuntimeError; @@ -62,6 +62,40 @@ pub fn dispatch( }, Command::Signer(signer) => match &signer.command { SignerCommand::Status => Ok(signer::status(config)), + SignerCommand::Session(session) => match &session.command { + SignerSessionCommand::List => Ok(signer::session_list(config)), + SignerSessionCommand::Show { session_id } => { + Ok(signer::session_show(config, session_id.as_str())) + } + SignerSessionCommand::ConnectBunker { url } => { + Ok(signer::session_connect_bunker(config, url.as_str())) + } + SignerSessionCommand::ConnectNostrconnect { + url, + client_secret_key, + } => Ok(signer::session_connect_nostrconnect( + config, + url.as_str(), + client_secret_key.as_str(), + )), + SignerSessionCommand::PublicKey { session_id } => { + Ok(signer::session_public_key(config, session_id.as_str())) + } + SignerSessionCommand::Authorize { session_id } => { + Ok(signer::session_authorize(config, session_id.as_str())) + } + SignerSessionCommand::RequireAuth { + session_id, + auth_url, + } => Ok(signer::session_require_auth( + config, + session_id.as_str(), + auth_url.as_str(), + )), + SignerSessionCommand::Close { session_id } => { + Ok(signer::session_close(config, session_id.as_str())) + } + }, }, Command::Doctor => doctor::report(config, logging), Command::Farm(farm_command) => match &farm_command.command { diff --git a/src/commands/signer.rs b/src/commands/signer.rs @@ -1,5 +1,8 @@ -use crate::domain::runtime::{CommandDisposition, CommandOutput, CommandView, SignerStatusView}; +use crate::domain::runtime::{ + CommandDisposition, CommandOutput, CommandView, SignerSessionActionView, SignerStatusView, +}; use crate::runtime::config::RuntimeConfig; +use crate::runtime::daemon::DaemonRpcError; use crate::runtime::signer::resolve_signer_status; pub fn status(config: &RuntimeConfig) -> CommandOutput { @@ -20,3 +23,141 @@ pub fn status(config: &RuntimeConfig) -> CommandOutput { } } } + +pub fn session_list(config: &RuntimeConfig) -> CommandOutput { + crate::runtime::daemon::signer_sessions(config) +} + +pub fn session_show(config: &RuntimeConfig, session_id: &str) -> CommandOutput { + session_action_output( + "show", + crate::runtime::daemon::signer_session_show(config, session_id), + ) +} + +pub fn session_connect_bunker(config: &RuntimeConfig, url: &str) -> CommandOutput { + session_action_output( + "connect_bunker", + crate::runtime::daemon::signer_session_connect_bunker(config, url), + ) +} + +pub fn session_connect_nostrconnect( + config: &RuntimeConfig, + url: &str, + client_secret_key: &str, +) -> CommandOutput { + session_action_output( + "connect_nostrconnect", + crate::runtime::daemon::signer_session_connect_nostrconnect(config, url, client_secret_key), + ) +} + +pub fn session_public_key(config: &RuntimeConfig, session_id: &str) -> CommandOutput { + session_action_output( + "public_key", + crate::runtime::daemon::signer_session_public_key(config, session_id), + ) +} + +pub fn session_authorize(config: &RuntimeConfig, session_id: &str) -> CommandOutput { + session_action_output( + "authorize", + crate::runtime::daemon::signer_session_authorize(config, session_id), + ) +} + +pub fn session_require_auth( + config: &RuntimeConfig, + session_id: &str, + auth_url: &str, +) -> CommandOutput { + session_action_output( + "require_auth", + crate::runtime::daemon::signer_session_require_auth(config, session_id, auth_url), + ) +} + +pub fn session_close(config: &RuntimeConfig, session_id: &str) -> CommandOutput { + session_action_output( + "close", + crate::runtime::daemon::signer_session_close(config, session_id), + ) +} + +fn session_action_output( + action: &str, + result: Result<SignerSessionActionView, DaemonRpcError>, +) -> CommandOutput { + match result { + Ok(view) => CommandOutput::success(CommandView::SignerSessionAction(view)), + Err(error) => { + let (disposition, view) = session_action_error_view(action, error); + match disposition { + CommandDisposition::Unconfigured => { + CommandOutput::unconfigured(CommandView::SignerSessionAction(view)) + } + CommandDisposition::ExternalUnavailable => { + CommandOutput::external_unavailable(CommandView::SignerSessionAction(view)) + } + CommandDisposition::Unsupported => { + CommandOutput::unsupported(CommandView::SignerSessionAction(view)) + } + CommandDisposition::InternalError => { + CommandOutput::internal_error(CommandView::SignerSessionAction(view)) + } + CommandDisposition::Success => { + CommandOutput::success(CommandView::SignerSessionAction(view)) + } + } + } + } +} + +fn session_action_error_view( + action: &str, + error: DaemonRpcError, +) -> (CommandDisposition, SignerSessionActionView) { + let (disposition, state, reason) = match error { + DaemonRpcError::Unconfigured(reason) + | DaemonRpcError::Unauthorized(reason) + | DaemonRpcError::MethodUnavailable(reason) => { + (CommandDisposition::Unconfigured, "unconfigured", reason) + } + DaemonRpcError::External(reason) => ( + CommandDisposition::ExternalUnavailable, + "unavailable", + reason, + ), + DaemonRpcError::InvalidResponse(reason) + | DaemonRpcError::Remote(reason) + | DaemonRpcError::UnknownJob(reason) => { + (CommandDisposition::InternalError, "error", reason) + } + }; + ( + disposition, + SignerSessionActionView { + action: action.to_owned(), + state: state.to_owned(), + source: "daemon signer session rpc · durable write plane".to_owned(), + session_id: None, + mode: None, + remote_signer_pubkey: None, + client_pubkey: None, + signer_pubkey: None, + user_pubkey: None, + relays: Vec::new(), + permissions: Vec::new(), + auth_required: None, + authorized: None, + auth_url: None, + expires_in_secs: None, + pubkey: None, + replayed: None, + required: None, + closed: None, + reason: Some(reason), + }, + ) +} diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -132,6 +132,7 @@ pub enum CommandView { SellMutation(SellMutationView), SellShow(SellShowView), Setup(SetupView), + SignerSessionAction(SignerSessionActionView), SignerStatus(SignerStatusView), Status(StatusView), SyncPull(SyncActionView), @@ -2153,6 +2154,47 @@ pub struct SignerStatusView { pub myc: Option<MycStatusView>, } +#[derive(Debug, Clone, Serialize)] +pub struct SignerSessionActionView { + pub action: String, + pub state: String, + pub source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_signer_pubkey: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_pubkey: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub signer_pubkey: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub user_pubkey: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub relays: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub permissions: Vec<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_required: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] + pub authorized: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_url: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_in_secs: Option<u64>, + #[serde(skip_serializing_if = "Option::is_none")] + pub pubkey: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub replayed: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] + pub closed: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, +} + impl SignerStatusView { pub fn disposition(&self) -> CommandDisposition { match self.state.as_str() { diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -116,6 +116,9 @@ fn render_human_view_to( CommandView::RpcStatus(view) => { render_rpc_status(stdout, view)?; } + CommandView::SignerSessionAction(view) => { + render_signer_session_action(stdout, view)?; + } CommandView::ConfigShow(view) => { render_config_show(stdout, view)?; } @@ -485,6 +488,10 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; } + CommandView::SignerSessionAction(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } CommandView::SignerStatus(view) => { serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; @@ -3158,7 +3165,15 @@ fn render_rpc_sessions(stdout: &mut dyn Write, view: &RpcSessionsView) -> Result } } else { let table = Table { - headers: &["session", "role", "auth", "authorized", "relays", "expires"], + headers: &[ + "session", + "role", + "user", + "auth", + "authorized", + "relays", + "expires", + ], rows: view .sessions .iter() @@ -3166,6 +3181,10 @@ fn render_rpc_sessions(stdout: &mut dyn Write, view: &RpcSessionsView) -> Result vec![ session.session_id.clone(), session.role.clone(), + session + .user_pubkey + .clone() + .unwrap_or_else(|| "n/a".to_owned()), yes_no(session.auth_required).to_owned(), yes_no(session.authorized).to_owned(), session.relay_count.to_string(), @@ -3186,6 +3205,74 @@ fn render_rpc_sessions(stdout: &mut dyn Write, view: &RpcSessionsView) -> Result Ok(()) } +fn render_signer_session_action( + stdout: &mut dyn Write, + view: &crate::domain::runtime::SignerSessionActionView, +) -> Result<(), RuntimeError> { + write_context( + stdout, + format!("signer session · {} · {}", view.action, view.state).as_str(), + )?; + let relays = view.relays.join(", "); + let permissions = view.permissions.join(", "); + let auth_required = view.auth_required.map(yes_no).unwrap_or("n/a"); + let authorized = view.authorized.map(yes_no).unwrap_or("n/a"); + let replayed = view.replayed.map(yes_no).unwrap_or("n/a"); + let required = view.required.map(yes_no).unwrap_or("n/a"); + let closed = view.closed.map(yes_no).unwrap_or("n/a"); + let expires = view + .expires_in_secs + .map(|secs| format!("{secs}s")) + .unwrap_or_else(|| "n/a".to_owned()); + render_pairs( + stdout, + "session", + &[ + ("id", view.session_id.as_deref().unwrap_or("n/a")), + ("mode", view.mode.as_deref().unwrap_or("n/a")), + ( + "remote signer", + view.remote_signer_pubkey.as_deref().unwrap_or("n/a"), + ), + ("client", view.client_pubkey.as_deref().unwrap_or("n/a")), + ( + "signer pubkey", + view.signer_pubkey.as_deref().unwrap_or("n/a"), + ), + ("user pubkey", view.user_pubkey.as_deref().unwrap_or("n/a")), + ("pubkey", view.pubkey.as_deref().unwrap_or("n/a")), + ("auth required", auth_required), + ("authorized", authorized), + ("auth url", view.auth_url.as_deref().unwrap_or("n/a")), + ("expires", expires.as_str()), + ("replayed", replayed), + ("required", required), + ("closed", closed), + ( + "relays", + if relays.is_empty() { + "n/a" + } else { + relays.as_str() + }, + ), + ( + "permissions", + if permissions.is_empty() { + "n/a" + } else { + permissions.as_str() + }, + ), + ], + )?; + if let Some(reason) = &view.reason { + writeln!(stdout, "reason: {reason}")?; + } + writeln!(stdout, "source: {}", view.source)?; + Ok(()) +} + fn render_sync_status(stdout: &mut dyn Write, view: &SyncStatusView) -> Result<(), RuntimeError> { write_context( stdout, @@ -4278,6 +4365,7 @@ fn human_command_name(view: &CommandView) -> &'static str { }, CommandView::SellShow(_) => "sell show", CommandView::Setup(_) => "setup", + CommandView::SignerSessionAction(_) => "signer session", CommandView::SignerStatus(_) => "signer status", CommandView::Status(_) => "status", CommandView::SyncPull(_) => "sync pull", diff --git a/src/runtime/daemon.rs b/src/runtime/daemon.rs @@ -7,7 +7,9 @@ use radroots_events::trade::RadrootsTradeOrder; use radroots_sdk::{ RadrootsSdkConfig, RadrootsdAuth, SdkPublishError, SdkRadrootsdFarmPublishOptions, SdkRadrootsdListingPublishOptions, SdkRadrootsdProfilePublishOptions, - SdkRadrootsdSignerAuthority, SdkRadrootsdSignerSessionRef, SdkTransportMode, SignerConfig, + SdkRadrootsdSignerAuthority, SdkRadrootsdSignerSessionConnectRequest, + SdkRadrootsdSignerSessionHandle, SdkRadrootsdSignerSessionRef, SdkRadrootsdSignerSessionRole, + SdkRadrootsdSignerSessionView, SdkTransportMode, SignerConfig, }; use reqwest::blocking::Client; use serde::de::DeserializeOwned; @@ -16,14 +18,15 @@ use serde_json::Value; use crate::domain::runtime::{ CommandOutput, CommandView, JobDetailView, JobSummaryView, RpcSessionView, RpcSessionsView, - RpcStatusView, + RpcStatusView, SignerSessionActionView, }; use crate::runtime::config::RuntimeConfig; use crate::runtime::provider; -use crate::runtime::signer::ActorWriteSignerAuthority; +use crate::runtime::signer::{ActorWriteSignerAuthority, configured_myc_signer_authority}; const RPC_SOURCE: &str = "daemon rpc · durable write plane"; const BRIDGE_SOURCE: &str = "daemon bridge · durable write plane"; +const SIGNER_SESSION_SOURCE: &str = "daemon signer session rpc · durable write plane"; const RPC_TIMEOUT_SECS: u64 = 2; #[derive(Debug)] @@ -378,6 +381,170 @@ pub fn sessions(config: &RuntimeConfig) -> CommandOutput { } } +pub fn signer_sessions(config: &RuntimeConfig) -> CommandOutput { + match signer_session_views(config) { + Ok((url, sessions)) => { + let entries = sessions + .into_iter() + .map(map_sdk_session_view) + .collect::<Vec<_>>(); + let state = if entries.is_empty() { "empty" } else { "ready" }; + CommandOutput::success(CommandView::RpcSessions(RpcSessionsView { + state: state.to_owned(), + source: SIGNER_SESSION_SOURCE.to_owned(), + url, + count: entries.len(), + reason: None, + sessions: entries, + actions: Vec::new(), + })) + } + Err(DaemonRpcError::External(reason)) => { + CommandOutput::external_unavailable(CommandView::RpcSessions(RpcSessionsView { + state: "unavailable".to_owned(), + source: SIGNER_SESSION_SOURCE.to_owned(), + url: config.rpc.url.clone(), + count: 0, + reason: Some(reason), + sessions: Vec::new(), + actions: vec![ + "start radrootsd and verify the actor write-plane endpoint".to_owned(), + ], + })) + } + Err(DaemonRpcError::Unconfigured(reason)) + | Err(DaemonRpcError::Unauthorized(reason)) + | Err(DaemonRpcError::MethodUnavailable(reason)) => { + CommandOutput::unconfigured(CommandView::RpcSessions(RpcSessionsView { + state: "unconfigured".to_owned(), + source: SIGNER_SESSION_SOURCE.to_owned(), + url: config.rpc.url.clone(), + count: 0, + reason: Some(reason), + sessions: Vec::new(), + actions: vec!["configure the radrootsd actor write-plane binding".to_owned()], + })) + } + Err(DaemonRpcError::InvalidResponse(reason)) + | Err(DaemonRpcError::Remote(reason)) + | Err(DaemonRpcError::UnknownJob(reason)) => { + CommandOutput::internal_error(CommandView::RpcSessions(RpcSessionsView { + state: "error".to_owned(), + source: SIGNER_SESSION_SOURCE.to_owned(), + url: config.rpc.url.clone(), + count: 0, + reason: Some(reason), + sessions: Vec::new(), + actions: Vec::new(), + })) + } + } +} + +pub fn signer_session_connect_bunker( + config: &RuntimeConfig, + url: &str, +) -> Result<SignerSessionActionView, DaemonRpcError> { + let mut request = SdkRadrootsdSignerSessionConnectRequest::bunker(url.to_owned()); + if let Some(authority) = configured_myc_signer_authority(config) { + request = request.with_signer_authority(sdk_signer_authority(&authority)); + } + signer_session_connect(config, "connect_bunker", &request) +} + +pub fn signer_session_connect_nostrconnect( + config: &RuntimeConfig, + url: &str, + client_secret_key: &str, +) -> Result<SignerSessionActionView, DaemonRpcError> { + let mut request = SdkRadrootsdSignerSessionConnectRequest::nostrconnect( + url.to_owned(), + client_secret_key.to_owned(), + ); + if let Some(authority) = configured_myc_signer_authority(config) { + request = request.with_signer_authority(sdk_signer_authority(&authority)); + } + signer_session_connect(config, "connect_nostrconnect", &request) +} + +pub fn signer_session_show( + config: &RuntimeConfig, + session_id: &str, +) -> Result<SignerSessionActionView, DaemonRpcError> { + let sdk = actor_write_sdk_client(config)?; + let session_ref = SdkRadrootsdSignerSessionRef::from_session_id(session_id.to_owned()); + let session = block_on_sdk(sdk.radrootsd().signer_sessions().status(&session_ref))? + .map_err(|error| DaemonRpcError::Remote(error.to_string()))?; + Ok(signer_session_view_action("show", session)) +} + +pub fn signer_session_public_key( + config: &RuntimeConfig, + session_id: &str, +) -> Result<SignerSessionActionView, DaemonRpcError> { + let sdk = actor_write_sdk_client(config)?; + let session_ref = SdkRadrootsdSignerSessionRef::from_session_id(session_id.to_owned()); + let public_key = block_on_sdk( + sdk.radrootsd() + .signer_sessions() + .get_public_key(&session_ref), + )? + .map_err(|error| DaemonRpcError::Remote(error.to_string()))?; + let mut view = signer_session_action("public_key", "ready"); + view.session_id = Some(session_id.to_owned()); + view.pubkey = Some(public_key.pubkey); + Ok(view) +} + +pub fn signer_session_authorize( + config: &RuntimeConfig, + session_id: &str, +) -> Result<SignerSessionActionView, DaemonRpcError> { + let sdk = actor_write_sdk_client(config)?; + let session_ref = SdkRadrootsdSignerSessionRef::from_session_id(session_id.to_owned()); + let result = block_on_sdk(sdk.radrootsd().signer_sessions().authorize(&session_ref))? + .map_err(|error| DaemonRpcError::Remote(error.to_string()))?; + let mut view = signer_session_action("authorize", "ready"); + view.session_id = Some(session_id.to_owned()); + view.authorized = Some(result.authorized); + view.replayed = Some(result.replayed); + Ok(view) +} + +pub fn signer_session_require_auth( + config: &RuntimeConfig, + session_id: &str, + auth_url: &str, +) -> Result<SignerSessionActionView, DaemonRpcError> { + let sdk = actor_write_sdk_client(config)?; + let session_ref = SdkRadrootsdSignerSessionRef::from_session_id(session_id.to_owned()); + let result = block_on_sdk( + sdk.radrootsd() + .signer_sessions() + .require_auth(&session_ref, auth_url), + )? + .map_err(|error| DaemonRpcError::Remote(error.to_string()))?; + let mut view = signer_session_action("require_auth", "ready"); + view.session_id = Some(session_id.to_owned()); + view.required = Some(result.required); + view.auth_url = Some(auth_url.to_owned()); + Ok(view) +} + +pub fn signer_session_close( + config: &RuntimeConfig, + session_id: &str, +) -> Result<SignerSessionActionView, DaemonRpcError> { + let sdk = actor_write_sdk_client(config)?; + let session_ref = SdkRadrootsdSignerSessionRef::from_session_id(session_id.to_owned()); + let result = block_on_sdk(sdk.radrootsd().signer_sessions().close(&session_ref))? + .map_err(|error| DaemonRpcError::Remote(error.to_string()))?; + let mut view = signer_session_action("close", "ready"); + view.session_id = Some(session_id.to_owned()); + view.closed = Some(result.closed); + Ok(view) +} + pub fn bridge_job_list(config: &RuntimeConfig) -> Result<Vec<JobSummaryView>, DaemonRpcError> { bridge_jobs(config).map(|jobs| jobs.into_iter().map(map_job_summary_view).collect()) } @@ -659,6 +826,83 @@ fn sdk_signer_authority(value: &ActorWriteSignerAuthority) -> SdkRadrootsdSigner } } +fn signer_session_views( + config: &RuntimeConfig, +) -> Result<(String, Vec<SdkRadrootsdSignerSessionView>), DaemonRpcError> { + let target = actor_write_target(config)?; + let sdk = radrootsd_sdk_client(&target)?; + let sessions = block_on_sdk(sdk.radrootsd().signer_sessions().list())? + .map_err(|error| DaemonRpcError::Remote(error.to_string()))?; + Ok((target.url, sessions)) +} + +fn signer_session_connect( + config: &RuntimeConfig, + action: &str, + request: &SdkRadrootsdSignerSessionConnectRequest, +) -> Result<SignerSessionActionView, DaemonRpcError> { + let sdk = actor_write_sdk_client(config)?; + let handle = block_on_sdk(sdk.radrootsd().signer_sessions().connect(request))? + .map_err(|error| DaemonRpcError::Remote(error.to_string()))?; + Ok(signer_session_handle_action(action, handle)) +} + +fn signer_session_action(action: &str, state: &str) -> SignerSessionActionView { + SignerSessionActionView { + action: action.to_owned(), + state: state.to_owned(), + source: SIGNER_SESSION_SOURCE.to_owned(), + session_id: None, + mode: None, + remote_signer_pubkey: None, + client_pubkey: None, + signer_pubkey: None, + user_pubkey: None, + relays: Vec::new(), + permissions: Vec::new(), + auth_required: None, + authorized: None, + auth_url: None, + expires_in_secs: None, + pubkey: None, + replayed: None, + required: None, + closed: None, + reason: None, + } +} + +fn signer_session_handle_action( + action: &str, + handle: SdkRadrootsdSignerSessionHandle, +) -> SignerSessionActionView { + let mut view = signer_session_action(action, "ready"); + view.session_id = Some(handle.session().session_id().to_owned()); + view.mode = Some(format!("{:?}", handle.mode())); + view.remote_signer_pubkey = Some(handle.remote_signer_pubkey().to_owned()); + view.client_pubkey = Some(handle.client_pubkey().to_owned()); + view.relays = handle.relays().to_vec(); + view +} + +fn signer_session_view_action( + action: &str, + session: SdkRadrootsdSignerSessionView, +) -> SignerSessionActionView { + let mut view = signer_session_action(action, "ready"); + view.session_id = Some(session.session().session_id().to_owned()); + view.signer_pubkey = Some(session.signer_pubkey); + view.user_pubkey = session.user_pubkey; + view.client_pubkey = Some(session.client_pubkey); + view.relays = session.relays; + view.permissions = session.permissions; + view.auth_required = Some(session.auth_required); + view.authorized = Some(session.authorized); + view.auth_url = session.auth_url; + view.expires_in_secs = session.expires_in_secs; + view +} + fn map_sdk_publish_error(error: SdkPublishError) -> DaemonRpcError { match error { SdkPublishError::Config(err) => DaemonRpcError::Unconfigured(err.to_string()), @@ -1093,6 +1337,28 @@ fn map_session_view(session: Nip46SessionRemote) -> RpcSessionView { } } +fn map_sdk_session_view(session: SdkRadrootsdSignerSessionView) -> RpcSessionView { + RpcSessionView { + session_id: session.session().session_id().to_owned(), + role: sdk_session_role(session.role).to_owned(), + client_pubkey: session.client_pubkey, + signer_pubkey: session.signer_pubkey, + user_pubkey: session.user_pubkey, + relay_count: session.relays.len(), + permissions_count: session.permissions.len(), + auth_required: session.auth_required, + authorized: session.authorized, + expires_in_secs: session.expires_in_secs, + } +} + +fn sdk_session_role(role: SdkRadrootsdSignerSessionRole) -> &'static str { + match role { + SdkRadrootsdSignerSessionRole::InboundLocalSigner => "inbound_local_signer", + SdkRadrootsdSignerSessionRole::OutboundRemoteSigner => "outbound_remote_signer", + } +} + pub fn bridge_source() -> &'static str { BRIDGE_SOURCE } diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -99,6 +99,27 @@ pub fn resolve_actor_write_authority( })) } +pub fn configured_myc_signer_authority( + config: &RuntimeConfig, +) -> Option<ActorWriteSignerAuthority> { + let binding = config.capability_binding(SIGNER_REMOTE_NIP46_CAPABILITY)?; + if binding.provider_runtime_id != SIGNER_BINDING_PROVIDER_RUNTIME_ID { + return None; + } + if !matches!( + binding.target_kind, + CapabilityBindingTargetKind::ManagedInstance + ) || binding.target != "default" + { + return None; + } + Some(ActorWriteSignerAuthority { + provider_runtime_id: SIGNER_BINDING_PROVIDER_RUNTIME_ID.to_owned(), + account_identity_id: binding.managed_account_ref.clone()?, + provider_signer_session_id: binding.signer_session_ref.clone(), + }) +} + fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { let (account_resolution, resolved_account_id) = match crate::runtime::accounts::resolve_account_resolution(config) { diff --git a/tests/job_rpc.rs b/tests/job_rpc.rs @@ -1,3 +1,4 @@ +use std::fs; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::Path; @@ -50,10 +51,78 @@ fn job_rpc_test_guard() -> MutexGuard<'static, ()> { .expect("job rpc test lock") } +fn write_user_config(workdir: &Path, contents: &str) { + let config_dir = workdir.join("home/.radroots/config/apps/cli"); + fs::create_dir_all(&config_dir).expect("user config dir"); + fs::write(config_dir.join("config.toml"), contents).expect("write user config"); +} + +fn signer_session_config(url: &str) -> String { + format!( + r#" +[[capability_binding]] +capability = "write_plane.trade_jsonrpc" +provider = "radrootsd" +target_kind = "explicit_endpoint" +target = "{url}" + +[[capability_binding]] +capability = "signer.remote_nip46" +provider = "myc" +target_kind = "managed_instance" +target = "default" +managed_account_ref = "acct_user" +signer_session_ref = "myc_conn_1" +"# + ) +} + +fn run_signer_session_command<F>( + workdir: &Path, + args: &[&str], + handler: F, +) -> (Value, MockRpcRequest) +where + F: Fn(&MockRpcRequest) -> MockRpcResponse + Send + Sync + 'static, +{ + let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); + let recorded = Arc::clone(&requests); + let server = MockRpcServer::start(move |request| { + let response = handler(&request); + recorded.lock().expect("record requests").push(request); + response + }); + write_user_config( + workdir, + signer_session_config(server.url().as_str()).as_str(), + ); + let output = job_rpc_command_in(workdir) + .env("RADROOTS_RPC_BEARER_TOKEN", "secret") + .args(args) + .output() + .expect("run signer session command"); + assert!( + output.status.success(), + "command {:?} stdout: {}\nstderr: {}", + args, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); + let request = requests + .lock() + .expect("recorded requests") + .first() + .expect("one recorded request") + .clone(); + (json, request) +} + #[derive(Debug, Clone)] struct MockRpcRequest { method: String, auth_header: Option<String>, + body: Value, } #[derive(Debug, Clone)] @@ -98,7 +167,7 @@ struct MockRpcServer { impl MockRpcServer { fn start<F>(handler: F) -> Self where - F: Fn(String, Option<String>) -> MockRpcResponse + Send + Sync + 'static, + F: Fn(MockRpcRequest) -> MockRpcResponse + Send + Sync + 'static, { let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock rpc listener"); listener @@ -110,22 +179,27 @@ 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(MockRpcRequest) -> MockRpcResponse + Send + Sync> = Arc::new(handler); let handle = thread::spawn(move || { while !shutdown_flag.load(Ordering::SeqCst) { match listener.accept() { Ok((mut stream, _)) => { - if let Ok(request) = read_request(&mut stream) { - let response = - handler(request.method.clone(), request.auth_header.clone()); - let _ = write_response(&mut stream, &response); + let _ = stream.set_nonblocking(false); + match read_request(&mut stream) { + Ok(request) => { + let response = handler(request); + let _ = write_response(&mut stream, &response); + } + Err(_) => {} } } Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { thread::sleep(Duration::from_millis(10)); } - Err(_) => break, + Err(_) => { + thread::sleep(Duration::from_millis(10)); + } } } }); @@ -201,6 +275,7 @@ fn read_request(stream: &mut TcpStream) -> Result<MockRpcRequest, String> { Ok(MockRpcRequest { method, auth_header, + body: envelope, }) } @@ -282,6 +357,27 @@ fn sample_bridge_status() -> Value { }) } +fn sample_signer_session() -> Value { + json!({ + "session_id": "sess_1", + "role": "outbound_remote_signer", + "client_pubkey": "client_pubkey", + "signer_pubkey": "myc_signer_pubkey", + "user_pubkey": "user_pubkey", + "relays": ["wss://relay.one"], + "permissions": ["sign_event", "nip44_encrypt"], + "auth_required": false, + "authorized": true, + "auth_url": null, + "expires_in_secs": 60, + "signer_authority": { + "provider_runtime_id": "myc", + "account_identity_id": "acct_user", + "provider_signer_session_id": "myc_conn_1" + } + }) +} + fn sample_job(job_id: &str, state: &str, terminal: bool, completed_at_unix: Option<u64>) -> Value { json!({ "job_id": job_id, @@ -315,14 +411,9 @@ fn rpc_status_reports_bridge_ready_via_daemon_rpc() { let _guard = job_rpc_test_guard(); 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("record requests") - .push(MockRpcRequest { - method: method.clone(), - auth_header: auth_header.clone(), - }); + let server = MockRpcServer::start(move |request| { + let method = request.method.clone(); + recorded.lock().expect("record requests").push(request); match method.as_str() { "bridge.status" => MockRpcResponse::success(sample_bridge_status()), _ => MockRpcResponse::rpc_error(-32601, "method not found"), @@ -356,14 +447,9 @@ fn rpc_sessions_ndjson_emits_public_session_entries() { let _guard = job_rpc_test_guard(); 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("record requests") - .push(MockRpcRequest { - method: method.clone(), - auth_header: auth_header.clone(), - }); + let server = MockRpcServer::start(move |request| { + let method = request.method.clone(); + recorded.lock().expect("record requests").push(request); match method.as_str() { "nip46.session.list" => MockRpcResponse::success(json!([ { @@ -416,18 +502,189 @@ fn rpc_sessions_ndjson_emits_public_session_entries() { } #[test] +fn signer_session_connect_preserves_configured_myc_authority() { + let _guard = job_rpc_test_guard(); + let dir = tempdir().expect("tempdir"); + + let (bunker_json, bunker_request) = run_signer_session_command( + dir.path(), + &[ + "--json", + "signer", + "session", + "connect-bunker", + "bunker://remote", + ], + |_| { + MockRpcResponse::success(json!({ + "session_id": "sess_bunker", + "mode": "Bunker", + "remote_signer_pubkey": "myc_signer_pubkey", + "client_pubkey": "client_pubkey", + "relays": ["wss://relay.one"] + })) + }, + ); + assert_eq!(bunker_json["action"], "connect_bunker"); + assert_eq!(bunker_json["session_id"], "sess_bunker"); + assert_eq!(bunker_json["remote_signer_pubkey"], "myc_signer_pubkey"); + + let nostr_dir = tempdir().expect("tempdir"); + let (nostr_json, nostr_request) = run_signer_session_command( + nostr_dir.path(), + &[ + "--json", + "signer", + "session", + "connect-nostrconnect", + "nostrconnect://remote", + "--client-secret-key", + "client-secret", + ], + |_| { + MockRpcResponse::success(json!({ + "session_id": "sess_nostrconnect", + "mode": "Nostrconnect", + "remote_signer_pubkey": "myc_signer_pubkey", + "client_pubkey": "client_pubkey", + "relays": ["wss://relay.two"] + })) + }, + ); + assert_eq!(nostr_json["action"], "connect_nostrconnect"); + assert_eq!(nostr_json["session_id"], "sess_nostrconnect"); + + for request in [&bunker_request, &nostr_request] { + assert_eq!(request.method, "nip46.connect"); + assert_eq!(request.auth_header.as_deref(), Some("Bearer secret")); + assert_eq!( + request.body["params"]["signer_authority"]["provider_runtime_id"], + "myc" + ); + assert_eq!( + request.body["params"]["signer_authority"]["account_identity_id"], + "acct_user" + ); + assert_eq!( + request.body["params"]["signer_authority"]["provider_signer_session_id"], + "myc_conn_1" + ); + } + assert_eq!(bunker_request.body["params"]["url"], "bunker://remote"); + assert_eq!(nostr_request.body["params"]["url"], "nostrconnect://remote"); + assert_eq!( + nostr_request.body["params"]["client_secret_key"], + "client-secret" + ); +} + +#[test] +fn signer_session_commands_cover_inspect_hydrate_authorize_require_auth_and_close() { + let _guard = job_rpc_test_guard(); + let dir = tempdir().expect("tempdir"); + let mut recorded = Vec::<MockRpcRequest>::new(); + + let (list_json, request) = + run_signer_session_command(dir.path(), &["--json", "signer", "session", "list"], |_| { + MockRpcResponse::success(json!([sample_signer_session()])) + }); + recorded.push(request); + assert_eq!(list_json["state"], "ready"); + assert_eq!(list_json["sessions"][0]["session_id"], "sess_1"); + assert_eq!(list_json["sessions"][0]["user_pubkey"], "user_pubkey"); + + let (show_json, request) = run_signer_session_command( + dir.path(), + &["--json", "signer", "session", "show", "sess_1"], + |_| MockRpcResponse::success(sample_signer_session()), + ); + recorded.push(request); + assert_eq!(show_json["action"], "show"); + assert_eq!(show_json["session_id"], "sess_1"); + assert_eq!(show_json["user_pubkey"], "user_pubkey"); + + let (public_key_json, request) = run_signer_session_command( + dir.path(), + &["--json", "signer", "session", "public-key", "sess_1"], + |_| MockRpcResponse::success(json!({ "pubkey": "user_pubkey" })), + ); + recorded.push(request); + assert_eq!(public_key_json["action"], "public_key"); + assert_eq!(public_key_json["pubkey"], "user_pubkey"); + + let (authorize_json, request) = run_signer_session_command( + dir.path(), + &["--json", "signer", "session", "authorize", "sess_1"], + |_| MockRpcResponse::success(json!({ "authorized": true, "replayed": false })), + ); + recorded.push(request); + assert_eq!(authorize_json["action"], "authorize"); + assert_eq!(authorize_json["authorized"], true); + assert_eq!(authorize_json["replayed"], false); + + let (require_auth_json, request) = run_signer_session_command( + dir.path(), + &[ + "--json", + "signer", + "session", + "require-auth", + "sess_1", + "--auth-url", + "https://auth.example", + ], + |_| MockRpcResponse::success(json!({ "required": true })), + ); + recorded.push(request); + assert_eq!(require_auth_json["action"], "require_auth"); + assert_eq!(require_auth_json["required"], true); + assert_eq!(require_auth_json["auth_url"], "https://auth.example"); + + let (close_json, request) = run_signer_session_command( + dir.path(), + &["--json", "signer", "session", "close", "sess_1"], + |_| MockRpcResponse::success(json!({ "closed": true })), + ); + recorded.push(request); + assert_eq!(close_json["action"], "close"); + assert_eq!(close_json["closed"], true); + + let methods = recorded + .iter() + .map(|request| request.method.as_str()) + .collect::<Vec<_>>(); + assert_eq!( + methods, + [ + "nip46.session.list", + "nip46.session.status", + "nip46.get_public_key", + "nip46.session.authorize", + "nip46.session.require_auth", + "nip46.session.close", + ] + ); + assert!( + recorded + .iter() + .all(|request| request.auth_header.as_deref() == Some("Bearer secret")) + ); + for request in recorded.iter().skip(1) { + assert_eq!(request.body["params"]["session_id"], "sess_1"); + } + assert_eq!( + recorded[4].body["params"]["auth_url"], + "https://auth.example" + ); +} + +#[test] fn job_commands_require_bridge_bearer_token() { let _guard = job_rpc_test_guard(); 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("record requests") - .push(MockRpcRequest { - method, - auth_header, - }); + let server = MockRpcServer::start(move |request| { + recorded.lock().expect("record requests").push(request); MockRpcResponse::rpc_error(-32601, "method not found") }); @@ -455,14 +712,9 @@ fn job_ls_and_get_report_retained_bridge_jobs() { let _guard = job_rpc_test_guard(); 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("record requests") - .push(MockRpcRequest { - method: method.clone(), - auth_header: auth_header.clone(), - }); + let server = MockRpcServer::start(move |request| { + let method = request.method.clone(); + recorded.lock().expect("record requests").push(request); match method.as_str() { "bridge.job.list" => { MockRpcResponse::success(json!([sample_job("job-123", "publishing", false, None)])) @@ -526,14 +778,9 @@ fn job_watch_ndjson_emits_one_frame_per_poll_until_terminal() { let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); let observed = Arc::clone(&requests); let counter = Arc::clone(&sequence); - let server = MockRpcServer::start(move |method, auth_header| { - observed - .lock() - .expect("record requests") - .push(MockRpcRequest { - method: method.clone(), - auth_header, - }); + let server = MockRpcServer::start(move |request| { + let method = request.method.clone(); + observed.lock().expect("record requests").push(request); match method.as_str() { "bridge.job.status" => { let mut count = counter.lock().expect("watch count"); @@ -590,14 +837,9 @@ fn job_watch_human_appends_snapshots_without_screen_clear() { let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); let observed = Arc::clone(&requests); let counter = Arc::clone(&sequence); - let server = MockRpcServer::start(move |method, auth_header| { - observed - .lock() - .expect("record requests") - .push(MockRpcRequest { - method: method.clone(), - auth_header, - }); + let server = MockRpcServer::start(move |request| { + let method = request.method.clone(); + observed.lock().expect("record requests").push(request); match method.as_str() { "bridge.job.status" => { let mut count = counter.lock().expect("watch count"); diff --git a/tests/listing.rs b/tests/listing.rs @@ -1537,6 +1537,7 @@ impl MockRpcServer { while !shutdown_flag.load(Ordering::SeqCst) { match listener.accept() { Ok((mut stream, _)) => { + let _ = stream.set_nonblocking(false); if let Ok(request) = read_request(&mut stream) { let response = handler(request.body.clone(), request.auth_header.clone()); @@ -1546,7 +1547,9 @@ impl MockRpcServer { Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { thread::sleep(Duration::from_millis(10)); } - Err(_) => break, + Err(_) => { + thread::sleep(Duration::from_millis(10)); + } } } }); diff --git a/tests/order.rs b/tests/order.rs @@ -283,6 +283,7 @@ impl MockRpcServer { while !shutdown_flag.load(Ordering::SeqCst) { match listener.accept() { Ok((mut stream, _)) => { + let _ = stream.set_nonblocking(false); if let Ok(request) = read_request(&mut stream) { let response = handler(request.body.clone(), request.auth_header.clone()); @@ -292,7 +293,9 @@ impl MockRpcServer { Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { thread::sleep(Duration::from_millis(10)); } - Err(_) => break, + Err(_) => { + thread::sleep(Duration::from_millis(10)); + } } } });