commit edc766dcb8f4607d7fd51c467e6099c6251ef88b
parent 4210455995ea516c84cd6788851b1ad8830a7be1
Author: triesap <tyson@radroots.org>
Date: Sat, 25 Apr 2026 09:55:36 +0000
signer: report kind specific readiness
Diffstat:
4 files changed, 336 insertions(+), 21 deletions(-)
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -2148,6 +2148,8 @@ pub struct SignerStatusView {
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub binding: SignerBindingStatusView,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub write_kinds: Vec<SignerWriteKindReadinessView>,
#[serde(skip_serializing_if = "Option::is_none")]
pub local: Option<LocalSignerStatusView>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -2155,6 +2157,16 @@ pub struct SignerStatusView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct SignerWriteKindReadinessView {
+ pub command: String,
+ pub event_kind: u32,
+ pub permission: String,
+ pub ready: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct SignerSessionActionView {
pub action: String,
pub state: String,
diff --git a/src/render/mod.rs b/src/render/mod.rs
@@ -11,7 +11,7 @@ use crate::domain::runtime::{
OrderSubmitWatchView, OrderWatchView, OrderWorkflowView, RelayListView, RpcSessionsView,
RpcStatusView, RuntimeActionView, RuntimeLogsView, RuntimeManagedConfigView, RuntimeStatusView,
SellAddView, SellCheckView, SellDraftMutationView, SellMutationView, SellShowView, SetupView,
- StatusView, SyncActionView, SyncStatusView, SyncWatchView,
+ SignerWriteKindReadinessView, StatusView, SyncActionView, SyncStatusView, SyncWatchView,
};
use crate::runtime::RuntimeError;
use crate::runtime::config::{OutputConfig, OutputFormat, Verbosity};
@@ -237,6 +237,10 @@ fn render_human_view_to(
signer_rows.push(("signer account id", account_id.as_str()));
}
render_pairs(stdout, "signer", signer_rows.as_slice())?;
+ if !view.write_kinds.is_empty() {
+ writeln!(stdout)?;
+ render_signer_write_kinds(stdout, &view.write_kinds)?;
+ }
writeln!(stdout)?;
render_account_resolution(stdout, &view.account_resolution)?;
if let Some(reason) = &view.reason {
@@ -4193,6 +4197,42 @@ fn render_myc_remote_session(
Ok(())
}
+fn render_signer_write_kinds(
+ stdout: &mut dyn Write,
+ write_kinds: &[SignerWriteKindReadinessView],
+) -> Result<(), RuntimeError> {
+ let table = Table {
+ headers: &["command", "kind", "ready", "permission"],
+ rows: write_kinds
+ .iter()
+ .map(|kind| {
+ vec![
+ kind.command.clone(),
+ kind.event_kind.to_string(),
+ yes_no(kind.ready).to_owned(),
+ kind.permission.clone(),
+ ]
+ })
+ .collect(),
+ };
+ render_table(stdout, &table)?;
+ let reasons = write_kinds
+ .iter()
+ .filter_map(|kind| {
+ kind.reason
+ .as_ref()
+ .map(|reason| (kind.command.as_str(), reason))
+ })
+ .collect::<Vec<_>>();
+ if !reasons.is_empty() {
+ writeln!(stdout)?;
+ for (command, reason) in reasons {
+ writeln!(stdout, "{command}: {reason}")?;
+ }
+ }
+ Ok(())
+}
+
fn render_myc_custody_identity(
stdout: &mut dyn Write,
heading: &str,
diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs
@@ -1,12 +1,14 @@
use crate::domain::runtime::{
IdentityPublicView, LocalSignerStatusView, MycRemoteSessionView, MycStatusView,
- SignerBindingStatusView, SignerStatusView,
+ SignerBindingStatusView, SignerStatusView, SignerWriteKindReadinessView,
};
use crate::runtime::accounts::{SHARED_ACCOUNT_STORE_SOURCE, empty_account_resolution_view};
use crate::runtime::config::{
CapabilityBindingConfig, CapabilityBindingTargetKind, RuntimeConfig,
SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend,
};
+use radroots_events::kinds::{KIND_FARM, KIND_LISTING, KIND_PROFILE};
+use radroots_events::trade::RadrootsTradeMessageType;
use radroots_nostr_accounts::prelude::RadrootsNostrAccountStatus;
use radroots_nostr_signer::prelude::{
RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability,
@@ -17,6 +19,12 @@ use serde::{Deserialize, Serialize};
const SIGNER_BINDING_PROVIDER_RUNTIME_ID: &str = "myc";
const SIGNER_BINDING_MODEL: &str = "session_authorized_remote_signer";
+#[derive(Debug, Clone, Copy)]
+struct CliWriteKind {
+ command: &'static str,
+ event_kind: u32,
+}
+
#[derive(Debug, Clone)]
struct MycBindingResolution {
view: SignerBindingStatusView,
@@ -131,14 +139,16 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView {
.map(|account| account.record.account_id.to_string()),
),
Err(error) => {
+ let reason = error.to_string();
return SignerStatusView {
mode: config.signer.backend.as_str().to_owned(),
state: "error".to_owned(),
source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(),
signer_account_id: None,
account_resolution: empty_account_resolution_view(),
- reason: Some(error.to_string()),
+ reason: Some(reason.clone()),
binding: disabled_binding_status(),
+ write_kinds: local_write_kind_readiness(false, Some(reason)),
local: None,
myc: None,
};
@@ -146,28 +156,32 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView {
};
let secret_backend = crate::runtime::accounts::secret_backend_status(config);
if secret_backend.state == "unavailable" {
+ let reason = secret_backend.reason.clone();
return SignerStatusView {
mode: config.signer.backend.as_str().to_owned(),
state: "unavailable".to_owned(),
source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(),
signer_account_id: resolved_account_id.clone(),
account_resolution: account_resolution.clone(),
- reason: secret_backend.reason,
+ reason: reason.clone(),
binding: disabled_binding_status(),
+ write_kinds: local_write_kind_readiness(false, reason),
local: None,
myc: None,
};
}
if secret_backend.state == "error" {
+ let reason = secret_backend.reason.clone();
return SignerStatusView {
mode: config.signer.backend.as_str().to_owned(),
state: "error".to_owned(),
source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(),
signer_account_id: resolved_account_id.clone(),
account_resolution: account_resolution.clone(),
- reason: secret_backend.reason,
+ reason: reason.clone(),
binding: disabled_binding_status(),
+ write_kinds: local_write_kind_readiness(false, reason),
local: None,
myc: None,
};
@@ -199,6 +213,7 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView {
account_resolution: account_resolution.clone(),
reason: None,
binding: disabled_binding_status(),
+ write_kinds: local_write_kind_readiness(true, None),
local: Some(LocalSignerStatusView {
account_id: local.account_id.to_string(),
public_identity: IdentityPublicView::from_public_identity(
@@ -223,6 +238,13 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView {
account.account_id
)),
binding: disabled_binding_status(),
+ write_kinds: local_write_kind_readiness(
+ false,
+ Some(format!(
+ "local account {} is present but not secret-backed",
+ account.account_id
+ )),
+ ),
local: Some(LocalSignerStatusView {
account_id: account.account_id.to_string(),
public_identity: IdentityPublicView::from_public_identity(&account.public_identity),
@@ -242,20 +264,28 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView {
account_resolution: account_resolution.clone(),
reason: crate::runtime::accounts::unresolved_account_reason(config).ok(),
binding: disabled_binding_status(),
+ write_kinds: local_write_kind_readiness(
+ false,
+ crate::runtime::accounts::unresolved_account_reason(config).ok(),
+ ),
local: None,
myc: None,
},
- Err(error) => SignerStatusView {
- mode: config.signer.backend.as_str().to_owned(),
- state: "error".to_owned(),
- source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(),
- signer_account_id: resolved_account_id,
- account_resolution,
- reason: Some(error.to_string()),
- binding: disabled_binding_status(),
- local: None,
- myc: None,
- },
+ Err(error) => {
+ let reason = error.to_string();
+ SignerStatusView {
+ mode: config.signer.backend.as_str().to_owned(),
+ state: "error".to_owned(),
+ source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(),
+ signer_account_id: resolved_account_id,
+ account_resolution,
+ reason: Some(reason.clone()),
+ binding: disabled_binding_status(),
+ write_kinds: local_write_kind_readiness(false, Some(reason)),
+ local: None,
+ myc: None,
+ }
+ }
}
}
@@ -268,6 +298,19 @@ fn resolve_myc_signer_status(config: &RuntimeConfig) -> SignerStatusView {
let resolution = resolve_myc_binding(config, &myc);
let binding = resolution.view;
let state = myc_signer_state(&myc, &binding).to_owned();
+ let resolved_session = binding
+ .resolved_signer_session_id
+ .as_deref()
+ .and_then(|session_id| {
+ myc.remote_sessions
+ .iter()
+ .find(|session| session.connection_id == session_id)
+ });
+ let write_kinds = myc_write_kind_readiness(
+ resolved_session,
+ binding.state.as_str(),
+ binding.reason.as_deref(),
+ );
SignerStatusView {
mode: config.signer.backend.as_str().to_owned(),
state,
@@ -284,6 +327,7 @@ fn resolve_myc_signer_status(config: &RuntimeConfig) -> SignerStatusView {
myc.reason.clone().or_else(|| binding.reason.clone())
},
binding,
+ write_kinds,
local: None,
myc: Some(myc),
}
@@ -422,7 +466,7 @@ fn resolve_myc_binding(config: &RuntimeConfig, myc: &MycStatusView) -> MycBindin
Some(1),
None,
format!(
- "configured signer session `{session_ref}` is not approved for `sign_event`"
+ "configured signer session `{session_ref}` is not approved for any cli write event kind"
),
);
}
@@ -468,7 +512,8 @@ fn resolve_myc_binding(config: &RuntimeConfig, myc: &MycStatusView) -> MycBindin
None,
Some(0),
None,
- "no authorized remote signer session currently exposes `sign_event`".to_owned(),
+ "no authorized remote signer session currently approves a cli write event kind"
+ .to_owned(),
);
}
@@ -479,7 +524,7 @@ fn resolve_myc_binding(config: &RuntimeConfig, myc: &MycStatusView) -> MycBindin
None,
Some(signing_sessions.len()),
None,
- "multiple authorized remote signer sessions expose `sign_event`; set managed_account_ref or signer_session_ref".to_owned(),
+ "multiple authorized remote signer sessions approve cli write event kinds; set managed_account_ref or signer_session_ref".to_owned(),
);
}
@@ -589,10 +634,94 @@ fn myc_signer_state(myc: &MycStatusView, binding: &SignerBindingStatusView) -> &
}
fn session_supports_signing(session: &MycRemoteSessionView) -> bool {
+ cli_write_kinds()
+ .iter()
+ .any(|kind| session_allows_event_kind(session, kind.event_kind))
+}
+
+fn cli_write_kinds() -> [CliWriteKind; 4] {
+ [
+ CliWriteKind {
+ command: "farm profile publish",
+ event_kind: KIND_PROFILE,
+ },
+ CliWriteKind {
+ command: "farm publish",
+ event_kind: KIND_FARM,
+ },
+ CliWriteKind {
+ command: "listing publish",
+ event_kind: KIND_LISTING,
+ },
+ CliWriteKind {
+ command: "order submit",
+ event_kind: u32::from(RadrootsTradeMessageType::OrderRequest.kind()),
+ },
+ ]
+}
+
+fn local_write_kind_readiness(
+ ready: bool,
+ reason: Option<String>,
+) -> Vec<SignerWriteKindReadinessView> {
+ cli_write_kinds()
+ .iter()
+ .map(|kind| SignerWriteKindReadinessView {
+ command: kind.command.to_owned(),
+ event_kind: kind.event_kind,
+ permission: "local_account_secret".to_owned(),
+ ready,
+ reason: if ready { None } else { reason.clone() },
+ })
+ .collect()
+}
+
+fn myc_write_kind_readiness(
+ session: Option<&MycRemoteSessionView>,
+ binding_state: &str,
+ binding_reason: Option<&str>,
+) -> Vec<SignerWriteKindReadinessView> {
+ cli_write_kinds()
+ .iter()
+ .map(|kind| {
+ let permission = format!("sign_event:{}", kind.event_kind);
+ match session {
+ Some(session) => {
+ let ready = session_allows_event_kind(session, kind.event_kind);
+ SignerWriteKindReadinessView {
+ command: kind.command.to_owned(),
+ event_kind: kind.event_kind,
+ permission,
+ ready,
+ reason: if ready {
+ None
+ } else {
+ Some(format!(
+ "resolved signer session `{}` is not approved for sign_event:{}",
+ session.connection_id, kind.event_kind
+ ))
+ },
+ }
+ }
+ None => SignerWriteKindReadinessView {
+ command: kind.command.to_owned(),
+ event_kind: kind.event_kind,
+ permission,
+ ready: false,
+ reason: binding_reason
+ .map(str::to_owned)
+ .or_else(|| Some(format!("signer binding is {binding_state}"))),
+ },
+ }
+ })
+ .collect()
+}
+
+fn session_allows_event_kind(session: &MycRemoteSessionView, kind: u32) -> bool {
session
.permissions
.iter()
- .any(|permission| permission == "sign_event" || permission.starts_with("sign_event:"))
+ .any(|permission| permission == "sign_event" || permission == &format!("sign_event:{kind}"))
}
fn local_availability(value: RadrootsNostrLocalSignerAvailability) -> &'static str {
diff --git a/tests/myc_status.rs b/tests/myc_status.rs
@@ -261,6 +261,82 @@ signer_session_ref = "{signer_session_ref}"
json["myc"]["local_signer"]["account_id"],
json["myc"]["remote_sessions"][0]["user_identity"]["id"]
);
+ assert!(
+ json["write_kinds"]
+ .as_array()
+ .expect("write kinds")
+ .iter()
+ .all(|kind| kind["ready"] == true)
+ );
+}
+
+#[test]
+fn signer_status_reports_kind_specific_myc_write_readiness() {
+ let _guard = myc_test_guard();
+ let dir = tempdir().expect("tempdir");
+ let payload = sample_status_payload_with_permissions(true, &["sign_event:30402"]);
+ let executable = write_fake_myc(
+ dir.path(),
+ successful_status_script(payload.to_string()).as_str(),
+ );
+ let managed_account_ref =
+ payload["signer_backend"]["remote_sessions"][0]["user_identity"]["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_user_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["state"], "ready");
+ assert_eq!(json["binding"]["state"], "ready");
+ let write_kinds = json["write_kinds"].as_array().expect("write kinds");
+ let listing = write_kinds
+ .iter()
+ .find(|kind| kind["event_kind"] == 30402)
+ .expect("listing kind");
+ assert_eq!(listing["command"], "listing publish");
+ assert_eq!(listing["ready"], true);
+ let order = write_kinds
+ .iter()
+ .find(|kind| kind["event_kind"] == 3422)
+ .expect("order kind");
+ assert_eq!(order["command"], "order submit");
+ assert_eq!(order["ready"], false);
+ assert!(
+ order["reason"]
+ .as_str()
+ .is_some_and(|value| value.contains("sign_event:3422"))
+ );
}
#[test]
@@ -431,7 +507,65 @@ signer_session_ref = "{signer_session_ref}"
assert!(
json["binding"]["reason"]
.as_str()
- .is_some_and(|value| value.contains("not approved for `sign_event`"))
+ .is_some_and(|value| value.contains("not approved for any cli write event kind"))
+ );
+}
+
+#[test]
+fn signer_status_does_not_treat_sign_event_star_as_wildcard() {
+ let _guard = myc_test_guard();
+ let dir = tempdir().expect("tempdir");
+ let payload = sample_status_payload_with_permissions(true, &["sign_event:*"]);
+ let executable = write_fake_myc(
+ dir.path(),
+ successful_status_script(payload.to_string()).as_str(),
+ );
+ let managed_account_ref =
+ payload["signer_backend"]["remote_sessions"][0]["user_identity"]["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_user_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["write_kinds"]
+ .as_array()
+ .expect("write kinds")
+ .iter()
+ .all(|kind| kind["ready"] == false)
);
}