commit d9f1278eae3ab6e5fd1b6a8f7b8cde24b609a6bb
parent c9ace3d4d43b8084bd42e48ab6e037298de0e242
Author: triesap <tyson@radroots.org>
Date: Thu, 9 Apr 2026 20:16:42 +0000
signer: resolve composed myc bindings
Diffstat:
7 files changed, 883 insertions(+), 37 deletions(-)
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -1340,6 +1340,7 @@ pub struct SignerStatusView {
pub account_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
+ pub binding: SignerBindingStatusView,
#[serde(skip_serializing_if = "Option::is_none")]
pub local: Option<LocalSignerStatusView>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -1369,6 +1370,29 @@ pub struct LocalSignerStatusView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct SignerBindingStatusView {
+ pub capability_id: String,
+ pub provider_runtime_id: String,
+ pub binding_model: String,
+ pub state: String,
+ pub source: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub target_kind: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub target: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub managed_account_ref: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub signer_session_ref: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub resolved_signer_session_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub matched_session_count: Option<usize>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct MycStatusView {
pub executable: String,
pub state: String,
@@ -1379,8 +1403,11 @@ pub struct MycStatusView {
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reasons: Vec<String>,
+ pub remote_session_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub local_signer: Option<LocalSignerStatusView>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub remote_sessions: Vec<MycRemoteSessionView>,
#[serde(skip_serializing_if = "Option::is_none")]
pub custody: Option<MycCustodyView>,
}
@@ -1397,6 +1424,16 @@ impl MycStatusView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct MycRemoteSessionView {
+ pub connection_id: String,
+ pub signer_identity: IdentityPublicView,
+ pub user_identity: IdentityPublicView,
+ pub relay_count: usize,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub permissions: Vec<String>,
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct MycCustodyView {
pub signer: MycCustodyIdentityView,
pub user: MycCustodyIdentityView,
diff --git a/src/render/mod.rs b/src/render/mod.rs
@@ -174,6 +174,8 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
writeln!(stdout, "reason: {reason}")?;
}
writeln!(stdout, "source: {}", view.source)?;
+ writeln!(stdout)?;
+ render_signer_binding(stdout, &view.binding)?;
if let Some(local) = &view.local {
writeln!(stdout)?;
render_local_signer(stdout, "local account", local)?;
@@ -2017,6 +2019,8 @@ fn render_myc_status(
if let Some(service_status) = &view.service_status {
rows.push(("service status", service_status.as_str()));
}
+ let remote_session_count = view.remote_session_count.to_string();
+ rows.push(("remote session count", remote_session_count.as_str()));
render_pairs(stdout, "myc", rows.as_slice())?;
if let Some(reason) = &view.reason {
writeln!(stdout, "reason: {reason}")?;
@@ -2029,6 +2033,10 @@ fn render_myc_status(
writeln!(stdout)?;
render_local_signer(stdout, "myc local signer", local_signer)?;
}
+ for session in &view.remote_sessions {
+ writeln!(stdout)?;
+ render_myc_remote_session(stdout, session)?;
+ }
if let Some(custody) = &view.custody {
writeln!(stdout)?;
render_myc_custody_identity(stdout, "myc custody signer", &custody.signer)?;
@@ -2040,6 +2048,64 @@ fn render_myc_status(
Ok(())
}
+fn render_signer_binding(
+ stdout: &mut dyn Write,
+ binding: &crate::domain::runtime::SignerBindingStatusView,
+) -> Result<(), RuntimeError> {
+ writeln!(stdout, "signer binding")?;
+ writeln!(stdout, " capability: {}", binding.capability_id)?;
+ writeln!(stdout, " provider: {}", binding.provider_runtime_id)?;
+ writeln!(stdout, " model: {}", binding.binding_model)?;
+ writeln!(stdout, " status: {}", binding.state)?;
+ writeln!(stdout, " source: {}", binding.source)?;
+ if let Some(target_kind) = &binding.target_kind {
+ writeln!(stdout, " target kind: {target_kind}")?;
+ }
+ if let Some(target) = &binding.target {
+ writeln!(stdout, " target: {target}")?;
+ }
+ if let Some(account_ref) = &binding.managed_account_ref {
+ writeln!(stdout, " managed account ref: {account_ref}")?;
+ }
+ if let Some(session_ref) = &binding.signer_session_ref {
+ writeln!(stdout, " signer session ref: {session_ref}")?;
+ }
+ if let Some(session_id) = &binding.resolved_signer_session_id {
+ writeln!(stdout, " resolved signer session id: {session_id}")?;
+ }
+ if let Some(count) = binding.matched_session_count {
+ writeln!(stdout, " matched session count: {count}")?;
+ }
+ if let Some(reason) = &binding.reason {
+ writeln!(stdout, " reason: {reason}")?;
+ }
+ Ok(())
+}
+
+fn render_myc_remote_session(
+ stdout: &mut dyn Write,
+ session: &crate::domain::runtime::MycRemoteSessionView,
+) -> Result<(), RuntimeError> {
+ writeln!(stdout, "myc remote session {}", session.connection_id)?;
+ writeln!(stdout, " signer id: {}", session.signer_identity.id)?;
+ writeln!(
+ stdout,
+ " signer npub: {}",
+ session.signer_identity.public_key_npub
+ )?;
+ writeln!(stdout, " user id: {}", session.user_identity.id)?;
+ writeln!(
+ stdout,
+ " user npub: {}",
+ session.user_identity.public_key_npub
+ )?;
+ writeln!(stdout, " relay count: {}", session.relay_count)?;
+ if !session.permissions.is_empty() {
+ writeln!(stdout, " permissions: {}", session.permissions.join(", "))?;
+ }
+ Ok(())
+}
+
fn render_myc_custody_identity(
stdout: &mut dyn Write,
heading: &str,
@@ -2327,7 +2393,9 @@ mod tests {
ready: false,
reason: None,
reasons: Vec::new(),
+ remote_session_count: 0,
local_signer: None,
+ remote_sessions: Vec::new(),
custody: None,
}));
let mut buffer = Vec::new();
diff --git a/src/runtime/config.rs b/src/runtime/config.rs
@@ -379,7 +379,7 @@ struct CapabilityBindingSpec {
binding_model: &'static str,
}
-const SIGNER_REMOTE_NIP46_CAPABILITY: &str = "signer.remote_nip46";
+pub(crate) const SIGNER_REMOTE_NIP46_CAPABILITY: &str = "signer.remote_nip46";
const WRITE_PLANE_TRADE_JSONRPC_CAPABILITY: &str = "write_plane.trade_jsonrpc";
const WORKFLOW_TRADE_CAPABILITY: &str = "workflow.trade";
const INFERENCE_HYF_STDIO_CAPABILITY: &str = "inference.hyf_stdio";
@@ -639,6 +639,12 @@ impl RuntimeConfig {
})
.collect()
}
+
+ pub fn capability_binding(&self, capability_id: &str) -> Option<&CapabilityBindingConfig> {
+ self.capability_bindings
+ .iter()
+ .find(|binding| binding.capability_id == capability_id)
+ }
}
fn resolve_migration(paths: PathsConfig, env: &dyn Environment) -> MigrationConfig {
diff --git a/src/runtime/myc.rs b/src/runtime/myc.rs
@@ -3,12 +3,14 @@ use std::process::{Child, Command, ExitStatus, Output, Stdio};
use std::thread;
use std::time::{Duration, Instant};
-use radroots_nostr_signer::prelude::RadrootsNostrLocalSignerCapability;
+use radroots_nostr_signer::prelude::{
+ RadrootsNostrLocalSignerCapability, RadrootsNostrRemoteSessionSignerCapability,
+};
use serde::Deserialize;
use crate::domain::runtime::{
IdentityPublicView, LocalSignerStatusView, MycCustodyIdentityView, MycCustodyView,
- MycStatusView,
+ MycRemoteSessionView, MycStatusView,
};
use crate::runtime::config::MycConfig;
@@ -104,34 +106,47 @@ pub fn resolve_status(config: &MycConfig) -> MycStatusView {
}
};
- let local_signer = payload
- .signer_backend
- .local_signer
- .map(local_signer_status_view);
- let custody = payload.custody.into_view();
- let state = if payload.ready {
+ let MycStatusPayload {
+ status,
+ ready,
+ reasons,
+ signer_backend,
+ custody,
+ } = payload;
+ let MycSignerBackendPayload {
+ local_signer,
+ remote_session_count,
+ remote_sessions,
+ } = signer_backend;
+
+ let remote_sessions = remote_sessions
+ .iter()
+ .map(remote_session_status_view)
+ .collect::<Vec<_>>();
+ let local_signer = local_signer.map(local_signer_status_view);
+ let remote_session_count = remote_session_count.max(remote_sessions.len());
+ let custody = custody.into_view();
+ let state = if ready {
"ready"
} else {
- match payload.status.as_str() {
+ match status.as_str() {
"degraded" => "degraded",
_ => "unavailable",
}
};
- let reason = primary_reason(
- payload.ready,
- payload.status.as_str(),
- payload.reasons.as_slice(),
- );
+ let reason = primary_reason(ready, status.as_str(), reasons.as_slice());
MycStatusView {
executable,
state: state.to_owned(),
source: "myc status command · local first".to_owned(),
- service_status: Some(payload.status),
- ready: payload.ready,
+ service_status: Some(status),
+ ready,
reason,
- reasons: payload.reasons,
+ reasons,
+ remote_session_count,
local_signer,
+ remote_sessions,
custody,
}
}
@@ -208,6 +223,23 @@ fn local_signer_status_view(
}
}
+fn remote_session_status_view(
+ capability: &RadrootsNostrRemoteSessionSignerCapability,
+) -> MycRemoteSessionView {
+ MycRemoteSessionView {
+ connection_id: capability.connection_id.to_string(),
+ signer_identity: IdentityPublicView::from_public_identity(&capability.signer_identity),
+ user_identity: IdentityPublicView::from_public_identity(&capability.user_identity),
+ relay_count: capability.relays.len(),
+ permissions: capability
+ .permissions
+ .as_slice()
+ .iter()
+ .map(ToString::to_string)
+ .collect(),
+ }
+}
+
fn primary_reason(ready: bool, service_status: &str, reasons: &[String]) -> Option<String> {
if ready {
return None;
@@ -228,7 +260,9 @@ fn unavailable_status(executable: String, state: &str, reason: String) -> MycSta
ready: false,
reason: Some(reason),
reasons: Vec::new(),
+ remote_session_count: 0,
local_signer: None,
+ remote_sessions: Vec::new(),
custody: None,
}
}
@@ -257,6 +291,10 @@ struct MycStatusPayload {
struct MycSignerBackendPayload {
#[serde(default)]
local_signer: Option<RadrootsNostrLocalSignerCapability>,
+ #[serde(default)]
+ remote_session_count: usize,
+ #[serde(default)]
+ remote_sessions: Vec<RadrootsNostrRemoteSessionSignerCapability>,
}
#[derive(Debug, Default, Deserialize)]
diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs
@@ -1,12 +1,21 @@
-use crate::domain::runtime::{IdentityPublicView, LocalSignerStatusView, SignerStatusView};
+use crate::domain::runtime::{
+ IdentityPublicView, LocalSignerStatusView, MycRemoteSessionView, MycStatusView,
+ SignerBindingStatusView, SignerStatusView,
+};
use crate::runtime::accounts::SHARED_ACCOUNT_STORE_SOURCE;
-use crate::runtime::config::{RuntimeConfig, SignerBackend};
+use crate::runtime::config::{
+ CapabilityBindingConfig, CapabilityBindingTargetKind, RuntimeConfig,
+ SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend,
+};
use radroots_nostr_accounts::prelude::RadrootsNostrSelectedAccountStatus;
use radroots_nostr_signer::prelude::{
RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability,
RadrootsNostrSignerCapability,
};
+const SIGNER_BINDING_PROVIDER_RUNTIME_ID: &str = "myc";
+const SIGNER_BINDING_MODEL: &str = "session_authorized_remote_signer";
+
pub fn resolve_signer_status(config: &RuntimeConfig) -> SignerStatusView {
match config.signer.backend {
SignerBackend::Local => resolve_local_signer_status(config),
@@ -23,6 +32,7 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView {
source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(),
account_id: None,
reason: secret_backend.reason,
+ binding: disabled_binding_status(),
local: None,
myc: None,
};
@@ -35,6 +45,7 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView {
source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(),
account_id: None,
reason: secret_backend.reason,
+ binding: disabled_binding_status(),
local: None,
myc: None,
};
@@ -64,6 +75,7 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView {
source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(),
account_id: Some(local.account_id.to_string()),
reason: None,
+ binding: disabled_binding_status(),
local: Some(LocalSignerStatusView {
account_id: local.account_id.to_string(),
public_identity: IdentityPublicView::from_public_identity(
@@ -86,6 +98,7 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView {
"local account {} is present but not secret-backed",
account.account_id
)),
+ binding: disabled_binding_status(),
local: Some(LocalSignerStatusView {
account_id: account.account_id.to_string(),
public_identity: IdentityPublicView::from_public_identity(&account.public_identity),
@@ -106,6 +119,7 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView {
"no local account is selected in {}",
config.account.store_path.display()
)),
+ binding: disabled_binding_status(),
local: None,
myc: None,
},
@@ -115,6 +129,7 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView {
source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(),
account_id: None,
reason: Some(error.to_string()),
+ binding: disabled_binding_status(),
local: None,
myc: None,
},
@@ -123,20 +138,333 @@ 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 state = myc_signer_state(&myc, &binding).to_owned();
SignerStatusView {
mode: config.signer.backend.as_str().to_owned(),
- state: myc.state.clone(),
- source: "myc status command · local first".to_owned(),
- account_id: myc
- .local_signer
- .as_ref()
- .map(|local| local.account_id.clone()),
- reason: myc.reason.clone(),
+ state,
+ source: if myc.state == "ready" {
+ binding.source.clone()
+ } else {
+ myc.source.clone()
+ },
+ account_id: resolve_myc_account_id(&binding, &myc),
+ reason: if myc.state == "ready" {
+ binding.reason.clone()
+ } else {
+ myc.reason.clone().or_else(|| binding.reason.clone())
+ },
+ binding,
local: None,
myc: Some(myc),
}
}
+fn disabled_binding_status() -> SignerBindingStatusView {
+ 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: "disabled".to_owned(),
+ source: "independent local signer mode".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(
+ "remote myc signer binding is disabled while cli signer mode is `local`".to_owned(),
+ ),
+ }
+}
+
+fn resolve_myc_binding_status(
+ config: &RuntimeConfig,
+ myc: &MycStatusView,
+) -> SignerBindingStatusView {
+ 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(),
+ ),
+ };
+ };
+
+ if !matches!(
+ binding.target_kind,
+ CapabilityBindingTargetKind::ManagedInstance
+ ) {
+ return binding_status(
+ binding,
+ "unsupported",
+ None,
+ None,
+ format!(
+ "signer.remote_nip46 only supports target_kind `managed_instance`; got `{}`",
+ binding.target_kind.as_str()
+ ),
+ );
+ }
+
+ if binding.target != "default" {
+ return binding_status(
+ binding,
+ "unsupported",
+ None,
+ None,
+ format!(
+ "managed myc target `{}` is not supported yet; use target `default`",
+ binding.target
+ ),
+ );
+ }
+
+ match myc.state.as_str() {
+ "ready" => {}
+ "unconfigured" => {
+ return binding_status(
+ binding,
+ "unconfigured",
+ None,
+ None,
+ myc.reason.clone().unwrap_or_else(|| {
+ "myc is not configured for composed signer bindings".to_owned()
+ }),
+ );
+ }
+ _ => {
+ return binding_status(
+ binding,
+ "unavailable",
+ None,
+ None,
+ myc.reason
+ .clone()
+ .unwrap_or_else(|| "myc is not ready for remote signer bindings".to_owned()),
+ );
+ }
+ }
+
+ let signing_sessions = myc
+ .remote_sessions
+ .iter()
+ .filter(|session| session_supports_signing(session))
+ .collect::<Vec<_>>();
+
+ if let Some(session_ref) = binding.signer_session_ref.as_deref() {
+ let Some(session) = myc
+ .remote_sessions
+ .iter()
+ .find(|session| session.connection_id == session_ref)
+ else {
+ return binding_status(
+ binding,
+ "unavailable",
+ None,
+ Some(0),
+ format!("configured signer session `{session_ref}` is not currently available"),
+ );
+ };
+
+ if !session_supports_signing(session) {
+ return binding_status(
+ binding,
+ "unauthorized",
+ None,
+ Some(1),
+ format!(
+ "configured signer session `{session_ref}` is not approved for `sign_event`"
+ ),
+ );
+ }
+
+ if let Some(account_ref) = binding.managed_account_ref.as_deref() {
+ if session.signer_identity.id != account_ref {
+ return binding_status(
+ binding,
+ "unauthorized",
+ None,
+ Some(1),
+ format!(
+ "configured signer session `{session_ref}` resolves signer `{}` instead of managed account `{account_ref}`",
+ session.signer_identity.id
+ ),
+ );
+ }
+ }
+
+ return binding_status(
+ binding,
+ "ready",
+ Some(session.connection_id.clone()),
+ Some(1),
+ String::new(),
+ );
+ }
+
+ if let Some(account_ref) = binding.managed_account_ref.as_deref() {
+ let matching_sessions = signing_sessions
+ .into_iter()
+ .filter(|session| session.signer_identity.id == account_ref)
+ .collect::<Vec<_>>();
+ return resolve_matching_sessions(binding, account_ref, matching_sessions);
+ }
+
+ if signing_sessions.is_empty() {
+ return binding_status(
+ binding,
+ "unavailable",
+ None,
+ Some(0),
+ "no authorized remote signer session currently exposes `sign_event`".to_owned(),
+ );
+ }
+
+ if signing_sessions.len() > 1 {
+ return binding_status(
+ binding,
+ "ambiguous",
+ None,
+ Some(signing_sessions.len()),
+ "multiple authorized remote signer sessions expose `sign_event`; set managed_account_ref or signer_session_ref".to_owned(),
+ );
+ }
+
+ let session = signing_sessions
+ .into_iter()
+ .next()
+ .expect("single matching signer session");
+ binding_status(
+ binding,
+ "ready",
+ Some(session.connection_id.clone()),
+ Some(1),
+ String::new(),
+ )
+}
+
+fn resolve_matching_sessions(
+ binding: &CapabilityBindingConfig,
+ account_ref: &str,
+ matching_sessions: Vec<&MycRemoteSessionView>,
+) -> SignerBindingStatusView {
+ if matching_sessions.is_empty() {
+ return binding_status(
+ binding,
+ "unavailable",
+ None,
+ Some(0),
+ format!(
+ "no authorized remote signer session currently matches managed account `{account_ref}`"
+ ),
+ );
+ }
+
+ if matching_sessions.len() > 1 {
+ return binding_status(
+ binding,
+ "ambiguous",
+ None,
+ Some(matching_sessions.len()),
+ format!(
+ "multiple authorized remote signer sessions currently match managed account `{account_ref}`; set signer_session_ref"
+ ),
+ );
+ }
+
+ let session = matching_sessions
+ .into_iter()
+ .next()
+ .expect("single matching signer session");
+ binding_status(
+ binding,
+ "ready",
+ Some(session.connection_id.clone()),
+ Some(1),
+ String::new(),
+ )
+}
+
+fn binding_status(
+ binding: &CapabilityBindingConfig,
+ state: &str,
+ resolved_signer_session_id: Option<String>,
+ matched_session_count: Option<usize>,
+ 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)
+ },
+ }
+}
+
+fn myc_signer_state(myc: &MycStatusView, binding: &SignerBindingStatusView) -> &'static str {
+ match myc.state.as_str() {
+ "degraded" => "degraded",
+ "unavailable" => "unavailable",
+ "unconfigured" => "unconfigured",
+ _ => match binding.state.as_str() {
+ "ready" => "ready",
+ "unavailable" => "unavailable",
+ _ => "unconfigured",
+ },
+ }
+}
+
+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
+ .iter()
+ .any(|permission| permission == "sign_event" || permission.starts_with("sign_event:"))
+}
+
fn local_availability(value: RadrootsNostrLocalSignerAvailability) -> &'static str {
match value {
RadrootsNostrLocalSignerAvailability::PublicOnly => "public_only",
diff --git a/tests/myc_status.rs b/tests/myc_status.rs
@@ -6,6 +6,7 @@ use std::sync::{Mutex, MutexGuard, OnceLock};
use assert_cmd::prelude::*;
use radroots_identity::RadrootsIdentity;
+use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionId;
use serde_json::{Value, json};
use tempfile::tempdir;
@@ -41,6 +42,12 @@ 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");
+}
+
#[test]
fn myc_status_reports_ready_for_valid_full_status_payload() {
let _guard = myc_test_guard();
@@ -68,6 +75,8 @@ fn myc_status_reports_ready_for_valid_full_status_payload() {
assert_eq!(json["source"], "myc status command · local first");
assert_eq!(json["ready"], true);
assert_eq!(json["service_status"], "healthy");
+ assert_eq!(json["remote_session_count"], 1);
+ assert_eq!(json["remote_sessions"][0]["permissions"][0], "sign_event");
assert_eq!(json["local_signer"]["availability"], "secret_backed");
assert_eq!(json["custody"]["signer"]["resolved"], true);
assert_eq!(
@@ -166,12 +175,10 @@ fn signer_status_reports_degraded_myc_backend_as_external_unavailable() {
assert_eq!(json["mode"], "myc");
assert_eq!(json["state"], "degraded");
assert_eq!(json["source"], "myc status command · local first");
- assert_eq!(
- json["account_id"],
- json["myc"]["local_signer"]["account_id"]
- );
+ assert_eq!(json["account_id"], Value::Null);
assert_eq!(json["myc"]["state"], "degraded");
assert_eq!(json["myc"]["service_status"], "degraded");
+ assert_eq!(json["binding"]["state"], "unconfigured");
assert!(
json["reason"]
.as_str()
@@ -180,6 +187,289 @@ fn signer_status_reports_degraded_myc_backend_as_external_unavailable() {
}
#[test]
+fn signer_status_reports_ready_for_configured_myc_managed_account_binding() {
+ let _guard = myc_test_guard();
+ let dir = tempdir().expect("tempdir");
+ let payload = sample_status_payload(true);
+ let executable = write_fake_myc(
+ dir.path(),
+ successful_status_script(payload.to_string()).as_str(),
+ );
+ let managed_account_ref = payload["signer_backend"]["local_signer"]["account_id"]
+ .as_str()
+ .expect("managed account ref");
+ let signer_session_ref = payload["signer_backend"]["remote_sessions"][0]["connection_id"]
+ .as_str()
+ .expect("signer session ref");
+ write_workspace_config(
+ dir.path(),
+ format!(
+ r#"
+[[capability_binding]]
+capability = "signer.remote_nip46"
+provider = "myc"
+target_kind = "managed_instance"
+target = "default"
+managed_account_ref = "{managed_account_ref}"
+signer_session_ref = "{signer_session_ref}"
+"#
+ )
+ .as_str(),
+ );
+
+ let output = cli_command_in(dir.path())
+ .args([
+ "--json",
+ "--signer",
+ "myc",
+ "--myc-executable",
+ executable.to_str().expect("executable path"),
+ "signer",
+ "status",
+ ])
+ .output()
+ .expect("run signer status");
+
+ assert!(output.status.success());
+ let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output");
+ assert_eq!(json["mode"], "myc");
+ assert_eq!(json["state"], "ready");
+ assert_eq!(json["source"], "workspace config [[capability_binding]]");
+ assert_eq!(json["account_id"], managed_account_ref);
+ assert_eq!(json["binding"]["state"], "ready");
+ assert_eq!(
+ json["binding"]["resolved_signer_session_id"],
+ signer_session_ref
+ );
+ assert_eq!(json["binding"]["managed_account_ref"], managed_account_ref);
+ assert_eq!(json["binding"]["signer_session_ref"], signer_session_ref);
+ assert_eq!(json["myc"]["remote_session_count"], 1);
+}
+
+#[test]
+fn signer_status_reports_unconfigured_when_myc_binding_is_missing() {
+ let _guard = myc_test_guard();
+ let dir = tempdir().expect("tempdir");
+ let executable = write_fake_myc(
+ dir.path(),
+ successful_status_script(sample_status_payload(true).to_string()).as_str(),
+ );
+
+ let output = cli_command_in(dir.path())
+ .args([
+ "--json",
+ "--signer",
+ "myc",
+ "--myc-executable",
+ executable.to_str().expect("executable path"),
+ "signer",
+ "status",
+ ])
+ .output()
+ .expect("run signer status");
+
+ assert_eq!(output.status.code(), Some(3));
+ let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output");
+ assert_eq!(json["state"], "unconfigured");
+ assert_eq!(json["binding"]["state"], "unconfigured");
+ assert!(
+ json["binding"]["reason"]
+ .as_str()
+ .is_some_and(|value| value.contains("configure [[capability_binding]]"))
+ );
+}
+
+#[test]
+fn signer_status_reports_unsupported_for_explicit_endpoint_binding() {
+ let _guard = myc_test_guard();
+ let dir = tempdir().expect("tempdir");
+ let executable = write_fake_myc(
+ dir.path(),
+ successful_status_script(sample_status_payload(true).to_string()).as_str(),
+ );
+ write_workspace_config(
+ dir.path(),
+ r#"
+[[capability_binding]]
+capability = "signer.remote_nip46"
+provider = "myc"
+target_kind = "explicit_endpoint"
+target = "https://myc.example.invalid"
+"#,
+ );
+
+ let output = cli_command_in(dir.path())
+ .args([
+ "--json",
+ "--signer",
+ "myc",
+ "--myc-executable",
+ executable.to_str().expect("executable path"),
+ "signer",
+ "status",
+ ])
+ .output()
+ .expect("run signer status");
+
+ assert_eq!(output.status.code(), Some(3));
+ let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output");
+ assert_eq!(json["state"], "unconfigured");
+ assert_eq!(json["binding"]["state"], "unsupported");
+ assert!(
+ json["binding"]["reason"]
+ .as_str()
+ .is_some_and(|value| value.contains("only supports target_kind `managed_instance`"))
+ );
+}
+
+#[test]
+fn signer_status_reports_ambiguous_for_accountless_myc_binding() {
+ let _guard = myc_test_guard();
+ let dir = tempdir().expect("tempdir");
+ let payload = sample_multi_session_status_payload();
+ let executable = write_fake_myc(
+ dir.path(),
+ successful_status_script(payload.to_string()).as_str(),
+ );
+ write_workspace_config(
+ dir.path(),
+ r#"
+[[capability_binding]]
+capability = "signer.remote_nip46"
+provider = "myc"
+target_kind = "managed_instance"
+target = "default"
+"#,
+ );
+
+ let output = cli_command_in(dir.path())
+ .args([
+ "--json",
+ "--signer",
+ "myc",
+ "--myc-executable",
+ executable.to_str().expect("executable path"),
+ "signer",
+ "status",
+ ])
+ .output()
+ .expect("run signer status");
+
+ assert_eq!(output.status.code(), Some(3));
+ let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output");
+ assert_eq!(json["state"], "unconfigured");
+ assert_eq!(json["binding"]["state"], "ambiguous");
+ assert_eq!(json["binding"]["matched_session_count"], 2);
+}
+
+#[test]
+fn signer_status_reports_unauthorized_for_session_without_sign_event_permission() {
+ let _guard = myc_test_guard();
+ let dir = tempdir().expect("tempdir");
+ let payload = sample_status_payload_with_permissions(true, &["nip44_encrypt"]);
+ let executable = write_fake_myc(
+ dir.path(),
+ successful_status_script(payload.to_string()).as_str(),
+ );
+ let managed_account_ref = payload["signer_backend"]["local_signer"]["account_id"]
+ .as_str()
+ .expect("managed account ref");
+ let signer_session_ref = payload["signer_backend"]["remote_sessions"][0]["connection_id"]
+ .as_str()
+ .expect("signer session ref");
+ write_workspace_config(
+ dir.path(),
+ format!(
+ r#"
+[[capability_binding]]
+capability = "signer.remote_nip46"
+provider = "myc"
+target_kind = "managed_instance"
+target = "default"
+managed_account_ref = "{managed_account_ref}"
+signer_session_ref = "{signer_session_ref}"
+"#
+ )
+ .as_str(),
+ );
+
+ let output = cli_command_in(dir.path())
+ .args([
+ "--json",
+ "--signer",
+ "myc",
+ "--myc-executable",
+ executable.to_str().expect("executable path"),
+ "signer",
+ "status",
+ ])
+ .output()
+ .expect("run signer status");
+
+ assert_eq!(output.status.code(), Some(3));
+ let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output");
+ assert_eq!(json["state"], "unconfigured");
+ assert_eq!(json["binding"]["state"], "unauthorized");
+ assert!(
+ json["binding"]["reason"]
+ .as_str()
+ .is_some_and(|value| value.contains("not approved for `sign_event`"))
+ );
+}
+
+#[test]
+fn signer_status_reports_unavailable_for_missing_bound_session() {
+ let _guard = myc_test_guard();
+ let dir = tempdir().expect("tempdir");
+ let payload = sample_status_payload(true);
+ let executable = write_fake_myc(
+ dir.path(),
+ successful_status_script(payload.to_string()).as_str(),
+ );
+ let managed_account_ref = payload["signer_backend"]["local_signer"]["account_id"]
+ .as_str()
+ .expect("managed account ref");
+ write_workspace_config(
+ dir.path(),
+ format!(
+ r#"
+[[capability_binding]]
+capability = "signer.remote_nip46"
+provider = "myc"
+target_kind = "managed_instance"
+target = "default"
+managed_account_ref = "{managed_account_ref}"
+signer_session_ref = "missing-session"
+"#
+ )
+ .as_str(),
+ );
+
+ let output = cli_command_in(dir.path())
+ .args([
+ "--json",
+ "--signer",
+ "myc",
+ "--myc-executable",
+ executable.to_str().expect("executable path"),
+ "signer",
+ "status",
+ ])
+ .output()
+ .expect("run signer status");
+
+ assert_eq!(output.status.code(), Some(4));
+ let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output");
+ assert_eq!(json["state"], "unavailable");
+ assert_eq!(json["binding"]["state"], "unavailable");
+ assert!(
+ json["binding"]["reason"]
+ .as_str()
+ .is_some_and(|value| value.contains("missing-session"))
+ );
+}
+
+#[test]
fn myc_status_reports_unavailable_when_executable_is_missing() {
let _guard = myc_test_guard();
let dir = tempdir().expect("tempdir");
@@ -289,8 +579,17 @@ fn successful_status_script(payload_json: String) -> String {
}
fn sample_status_payload(ready: bool) -> Value {
+ sample_status_payload_with_permissions(ready, &["sign_event"])
+}
+
+fn sample_status_payload_with_permissions(ready: bool, permissions: &[&str]) -> Value {
let signer_identity = RadrootsIdentity::generate().to_public();
let user_identity = RadrootsIdentity::generate().to_public();
+ let session_id = RadrootsNostrSignerConnectionId::new_v7().to_string();
+ let signer_id = signer_identity.id.clone();
+ let signer_public_key_hex = signer_identity.public_key_hex.clone();
+ let user_id = user_identity.id.clone();
+ let user_public_key_hex = user_identity.public_key_hex.clone();
let service_status = if ready { "healthy" } else { "degraded" };
let reasons = if ready {
Vec::<String>::new()
@@ -304,25 +603,92 @@ fn sample_status_payload(ready: bool) -> Value {
"reasons": reasons,
"signer_backend": {
"local_signer": {
- "account_id": signer_identity.id,
- "public_identity": signer_identity,
+ "account_id": signer_id,
+ "public_identity": signer_identity.clone(),
"availability": "SecretBacked"
+ },
+ "remote_session_count": 1,
+ "remote_sessions": [
+ {
+ "connection_id": session_id,
+ "signer_identity": signer_identity,
+ "user_identity": user_identity.clone(),
+ "relays": ["wss://relay.example.test"],
+ "permissions": permissions.join(",")
+ }
+ ]
+ },
+ "custody": {
+ "signer": {
+ "resolved": true,
+ "selected_account_id": "signer-account",
+ "selected_account_state": "ready",
+ "identity_id": signer_id,
+ "public_key_hex": signer_public_key_hex
+ },
+ "user": {
+ "resolved": true,
+ "selected_account_id": "user-account",
+ "selected_account_state": "public_only",
+ "identity_id": user_id,
+ "public_key_hex": user_public_key_hex
}
+ }
+ })
+}
+
+fn sample_multi_session_status_payload() -> Value {
+ let first_signer = RadrootsIdentity::generate().to_public();
+ let first_user = RadrootsIdentity::generate().to_public();
+ let second_signer = RadrootsIdentity::generate().to_public();
+ let second_user = RadrootsIdentity::generate().to_public();
+ let first_signer_id = first_signer.id.clone();
+ let first_signer_public_key_hex = first_signer.public_key_hex.clone();
+ let first_user_id = first_user.id.clone();
+ let first_user_public_key_hex = first_user.public_key_hex.clone();
+
+ json!({
+ "status": "healthy",
+ "ready": true,
+ "reasons": [],
+ "signer_backend": {
+ "local_signer": {
+ "account_id": first_signer_id,
+ "public_identity": first_signer.clone(),
+ "availability": "SecretBacked"
+ },
+ "remote_session_count": 2,
+ "remote_sessions": [
+ {
+ "connection_id": RadrootsNostrSignerConnectionId::new_v7().to_string(),
+ "signer_identity": first_signer,
+ "user_identity": first_user.clone(),
+ "relays": ["wss://relay.example.test"],
+ "permissions": "sign_event"
+ },
+ {
+ "connection_id": RadrootsNostrSignerConnectionId::new_v7().to_string(),
+ "signer_identity": second_signer,
+ "user_identity": second_user,
+ "relays": ["wss://relay-secondary.example.test"],
+ "permissions": "sign_event"
+ }
+ ]
},
"custody": {
"signer": {
"resolved": true,
"selected_account_id": "signer-account",
"selected_account_state": "ready",
- "identity_id": signer_identity.id,
- "public_key_hex": signer_identity.public_key_hex
+ "identity_id": first_signer_id,
+ "public_key_hex": first_signer_public_key_hex
},
"user": {
"resolved": true,
"selected_account_id": "user-account",
"selected_account_state": "public_only",
- "identity_id": user_identity.id,
- "public_key_hex": user_identity.public_key_hex
+ "identity_id": first_user_id,
+ "public_key_hex": first_user_public_key_hex
}
}
})
diff --git a/tests/signer_status.rs b/tests/signer_status.rs
@@ -71,6 +71,8 @@ fn signer_status_reports_local_ready_when_account_exists() {
assert_eq!(json["source"], "shared account store · local first");
assert_eq!(json["account_id"], json["local"]["account_id"]);
assert_eq!(json["reason"], Value::Null);
+ assert_eq!(json["binding"]["state"], "disabled");
+ assert_eq!(json["binding"]["source"], "independent local signer mode");
assert_eq!(json["local"]["availability"], "secret_backed");
assert_eq!(json["local"]["secret_backed"], true);
assert_eq!(json["local"]["backend"], "encrypted_file");
@@ -91,6 +93,7 @@ fn signer_status_reports_local_unconfigured_when_no_account_is_selected() {
let json: Value = serde_json::from_str(stdout.as_str()).expect("json output");
assert_eq!(json["mode"], "local");
assert_eq!(json["state"], "unconfigured");
+ assert_eq!(json["binding"]["state"], "disabled");
assert!(
json["reason"]
.as_str()