myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

commit 7fef49814b6aee98b9bfd042d9212e75f939a110
parent 3b61db85298979d10571aa8cf1b50e86bef1cb52
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 12:01:02 +0000

cli: add discovery commands and proof lane

- add discovery render-nip05 render-nip89 and publish-nip89 operator commands
- keep handler publication explicit and surface published event details through the CLI
- add relay-backed proof coverage for NIP-89 publication authoring tags and runtime audit records
- validate with cargo fmt --check cargo test --locked --test nip46_e2e explicit_nip89_publish_uses_app_identity_and_records_audit and cargo test --locked

Diffstat:
Msrc/cli.rs | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/nip46_e2e.rs | 107++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 161 insertions(+), 1 deletion(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -13,6 +13,7 @@ use crate::app::MycRuntime; use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; use crate::config::{DEFAULT_CONFIG_PATH, MycConfig}; use crate::control::{accept_client_uri, authorize_auth_challenge, parse_permission_values}; +use crate::discovery::{MycDiscoveryContext, publish_nip89_event}; use crate::error::MycError; use crate::logging; @@ -45,6 +46,10 @@ pub enum MycCommand { #[command(subcommand)] command: MycConnectCommand, }, + Discovery { + #[command(subcommand)] + command: MycDiscoveryCommand, + }, } #[derive(Debug, Subcommand)] @@ -104,6 +109,18 @@ pub enum MycConnectCommand { }, } +#[derive(Debug, Subcommand)] +pub enum MycDiscoveryCommand { + RenderNip05 { + #[arg(long)] + out: Option<PathBuf>, + #[arg(long)] + stdout: bool, + }, + RenderNip89, + PublishNip89, +} + #[derive(Debug, Args)] pub struct MycConnectionApprovalArgs { #[arg(long)] @@ -253,6 +270,43 @@ pub async fn run_from_env() -> Result<(), MycError> { } } } + MycCommand::Discovery { command } => { + let runtime = MycRuntime::bootstrap(config)?; + match command { + MycDiscoveryCommand::RenderNip05 { out, stdout } => { + if stdout && out.is_some() { + return Err(MycError::InvalidOperation( + "discovery render-nip05 cannot use --stdout and --out together" + .to_owned(), + )); + } + let context = MycDiscoveryContext::from_runtime(&runtime)?; + if stdout || (out.is_none() && context.nip05_output_path().is_none()) { + println!("{}", context.render_nip05_json_pretty()?); + Ok(()) + } else { + let output = context.write_nip05_document( + out.as_deref().or(context.nip05_output_path()).ok_or_else(|| { + MycError::InvalidOperation( + "discovery render-nip05 requires --out or discovery.nip05_output_path" + .to_owned(), + ) + })?, + )?; + print_json(&output) + } + } + MycDiscoveryCommand::RenderNip89 => { + let output = + MycDiscoveryContext::from_runtime(&runtime)?.render_nip89_output()?; + print_json(&output) + } + MycDiscoveryCommand::PublishNip89 => { + let output = publish_nip89_event(&runtime).await?; + print_json(&output) + } + } + } } } @@ -407,6 +461,7 @@ fn operation_kind_label(kind: MycOperationAuditKind) -> String { MycOperationAuditKind::ConnectAcceptPublish => "connect_accept_publish".to_owned(), MycOperationAuditKind::AuthReplayPublish => "auth_replay_publish".to_owned(), MycOperationAuditKind::AuthReplayRestore => "auth_replay_restore".to_owned(), + MycOperationAuditKind::DiscoveryHandlerPublish => "discovery_handler_publish".to_owned(), } } diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs @@ -6,7 +6,7 @@ use futures_util::{SinkExt, StreamExt}; use myc::control; use myc::{ MycConfig, MycConnectionApproval, MycOperationAuditKind, MycOperationAuditOutcome, - MycOperationAuditRecord, MycRuntime, + MycOperationAuditRecord, MycRuntime, publish_nip89_event, }; use nostr::filter::MatchEventOptions; use nostr::nips::nip44; @@ -339,6 +339,44 @@ impl MycTestRuntime { _temp: temp, } } + + fn new_with_discovery(relay_url: &str, approval: MycConnectionApproval) -> Self { + let temp = tempfile::tempdir().expect("tempdir"); + let mut config = MycConfig::default(); + config.paths.state_dir = temp.path().join("state"); + config.paths.signer_identity_path = temp.path().join("signer.json"); + config.paths.user_identity_path = temp.path().join("user.json"); + config.policy.connection_approval = approval; + config.transport.connect_timeout_secs = 1; + config.discovery.enabled = true; + config.discovery.domain = Some("signer.example.com".to_owned()); + config.discovery.public_relays = vec![relay_url.to_owned()]; + config.discovery.publish_relays = vec![relay_url.to_owned()]; + config.discovery.nostrconnect_url_template = + Some("https://signer.example.com/connect?uri=<nostrconnect>".to_owned()); + config.discovery.app_identity_path = Some(temp.path().join("app.json")); + write_identity( + &config.paths.signer_identity_path, + "1111111111111111111111111111111111111111111111111111111111111111", + ); + write_identity( + &config.paths.user_identity_path, + "2222222222222222222222222222222222222222222222222222222222222222", + ); + write_identity( + config + .discovery + .app_identity_path + .as_ref() + .expect("app identity path"), + "6666666666666666666666666666666666666666666666666666666666666666", + ); + + Self { + runtime: MycRuntime::bootstrap(config).expect("runtime"), + _temp: temp, + } + } } fn write_identity(path: &std::path::Path, secret_key: &str) { @@ -872,3 +910,70 @@ async fn auth_replay_restores_pending_request_until_publish_succeeds() -> TestRe Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn explicit_nip89_publish_uses_app_identity_and_records_audit() -> TestResult<()> { + let relay = TestRelay::spawn().await?; + let test_runtime = + MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); + let runtime = test_runtime.runtime; + let app_identity = RadrootsIdentity::load_from_path_auto( + runtime + .config() + .discovery + .app_identity_path + .as_ref() + .expect("app identity path"), + )?; + + relay + .queue_publish_outcomes(app_identity.public_key(), &[true]) + .await; + + let published = publish_nip89_event(&runtime).await?; + let published_event_id = published.event.id.to_hex(); + let published_events = relay + .wait_for_published_events_by_author(app_identity.public_key(), 1) + .await?; + let event = &published_events[0]; + let event_json = event.as_json(); + + assert_eq!( + published.author_public_key_hex, + app_identity.public_key_hex() + ); + assert_eq!( + published.signer_public_key_hex, + runtime.signer_identity().public_key_hex() + ); + assert_eq!(event.kind.as_u16(), 31_990); + assert!(event_json.contains("\"24133\"")); + assert!(event_json.contains("\"relay\"")); + assert!(event_json.contains("\"nostrconnect_url\"")); + assert_eq!(published.relay_count, 1); + assert_eq!(published.acknowledged_relay_count, 1); + + let operation_audit = wait_for_operation_audit_count(&runtime, 1).await?; + assert_eq!( + operation_audit[0].operation, + MycOperationAuditKind::DiscoveryHandlerPublish + ); + assert_eq!( + operation_audit[0].outcome, + MycOperationAuditOutcome::Succeeded + ); + assert!(operation_audit[0].connection_id.is_none()); + assert_eq!( + operation_audit[0].request_id.as_deref(), + Some(published_event_id.as_str()) + ); + assert_eq!(operation_audit[0].relay_count, 1); + assert_eq!(operation_audit[0].acknowledged_relay_count, 1); + assert!( + operation_audit[0] + .relay_outcome_summary + .contains("1/1 relays acknowledged publish") + ); + + Ok(()) +}