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:
| M | src/cli.rs | | | 55 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | tests/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(())
+}