commit ba89cdabd338b76ff9da0f1550df2a71b107ac2e
parent 7f4d506df78500f38b130eb60dab8274c729ab64
Author: triesap <tyson@radroots.org>
Date: Sun, 22 Mar 2026 01:56:14 +0000
cli: surface runtime audit and proof lane
- extend audit list to return signer request audit and runtime operation audit
- add runtime audit coverage for publish rejection and auth replay restoration
- assert live listener and connect accept write operator-visible audit records
- validate with cargo metadata --format-version 1 --no-deps, cargo fmt --check, cargo check --locked, and cargo test --locked
Diffstat:
| M | src/cli.rs | | | 143 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- |
| M | tests/nip46_e2e.rs | | | 155 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- |
2 files changed, 289 insertions(+), 9 deletions(-)
diff --git a/src/cli.rs b/src/cli.rs
@@ -1,13 +1,15 @@
use std::path::{Path, PathBuf};
-use clap::{Args, Parser, Subcommand};
+use clap::{Args, Parser, Subcommand, ValueEnum};
use radroots_nostr_connect::prelude::RadrootsNostrConnectPermissions;
use radroots_nostr_signer::prelude::{
RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord,
+ RadrootsNostrSignerRequestAuditRecord,
};
use serde::Serialize;
use crate::app::MycRuntime;
+use crate::audit::MycOperationAuditRecord;
use crate::config::{DEFAULT_CONFIG_PATH, MycConfig};
use crate::control::{accept_client_uri, authorize_auth_challenge, parse_permission_values};
use crate::error::MycError;
@@ -57,9 +59,18 @@ pub enum MycAuditCommand {
List {
#[arg(long)]
connection_id: Option<String>,
+ #[arg(long, value_enum, default_value_t = MycAuditScope::All)]
+ scope: MycAuditScope,
},
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
+pub enum MycAuditScope {
+ All,
+ Request,
+ Operation,
+}
+
#[derive(Debug, Subcommand)]
pub enum MycAuthCommand {
Require {
@@ -98,6 +109,12 @@ pub struct MycConnectionReasonArgs {
reason: Option<String>,
}
+#[derive(Debug, Serialize, PartialEq, Eq)]
+pub struct MycAuditListOutput {
+ pub signer_request_audit: Vec<RadrootsNostrSignerRequestAuditRecord>,
+ pub runtime_operation_audit: Vec<MycOperationAuditRecord>,
+}
+
pub async fn run_from_env() -> Result<(), MycError> {
let cli = MycCli::parse();
let config = load_config(cli.config.as_deref())?;
@@ -144,13 +161,13 @@ pub async fn run_from_env() -> Result<(), MycError> {
let runtime = MycRuntime::bootstrap(config)?;
let manager = runtime.signer_manager()?;
match command {
- MycAuditCommand::List { connection_id } => {
- if let Some(connection_id) = connection_id {
- let connection_id = parse_connection_id(&connection_id)?;
- print_json(&manager.audit_records_for_connection(&connection_id)?)
- } else {
- print_json(&manager.list_audit_records()?)
- }
+ MycAuditCommand::List {
+ connection_id,
+ scope,
+ } => {
+ let output =
+ load_audit_output(&runtime, &manager, connection_id.as_deref(), scope)?;
+ print_json(&output)
}
}
}
@@ -211,6 +228,32 @@ fn granted_permissions_for_approval(
Ok(connection.requested_permissions.clone())
}
+fn load_audit_output(
+ runtime: &MycRuntime,
+ manager: &radroots_nostr_signer::prelude::RadrootsNostrSignerManager,
+ connection_id: Option<&str>,
+ scope: MycAuditScope,
+) -> Result<MycAuditListOutput, MycError> {
+ let connection_id = connection_id.map(parse_connection_id).transpose()?;
+ let signer_request_audit = match (scope, connection_id.as_ref()) {
+ (MycAuditScope::Operation, _) => Vec::new(),
+ (_, Some(connection_id)) => manager.audit_records_for_connection(connection_id)?,
+ (_, None) => manager.list_audit_records()?,
+ };
+ let runtime_operation_audit = match (scope, connection_id.as_ref()) {
+ (MycAuditScope::Request, _) => Vec::new(),
+ (_, Some(connection_id)) => runtime
+ .operation_audit_store()
+ .list_for_connection(connection_id)?,
+ (_, None) => runtime.operation_audit_store().list()?,
+ };
+
+ Ok(MycAuditListOutput {
+ signer_request_audit,
+ runtime_operation_audit,
+ })
+}
+
fn print_json<T>(value: &T) -> Result<(), MycError>
where
T: Serialize,
@@ -218,3 +261,87 @@ where
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use std::path::PathBuf;
+
+ use radroots_identity::RadrootsIdentity;
+ use radroots_nostr_connect::prelude::RadrootsNostrConnectRequest;
+ use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionDraft;
+
+ use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord};
+ use crate::config::MycConfig;
+
+ use super::{MycAuditScope, load_audit_output};
+ use crate::app::MycRuntime;
+
+ fn write_identity(path: &std::path::Path, secret_key: &str) {
+ RadrootsIdentity::from_secret_key_str(secret_key)
+ .expect("identity")
+ .save_json(path)
+ .expect("save identity");
+ }
+
+ fn runtime() -> MycRuntime {
+ let temp = tempfile::tempdir().expect("tempdir").keep();
+ let mut config = MycConfig::default();
+ config.paths.state_dir = PathBuf::from(&temp).join("state");
+ config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json");
+ config.paths.user_identity_path = PathBuf::from(&temp).join("user.json");
+ write_identity(
+ &config.paths.signer_identity_path,
+ "1111111111111111111111111111111111111111111111111111111111111111",
+ );
+ write_identity(
+ &config.paths.user_identity_path,
+ "2222222222222222222222222222222222222222222222222222222222222222",
+ );
+ MycRuntime::bootstrap(config).expect("runtime")
+ }
+
+ #[test]
+ fn audit_output_surfaces_both_request_and_operation_records() {
+ let runtime = runtime();
+ let manager = runtime.signer_manager().expect("manager");
+ let connection = manager
+ .register_connection(RadrootsNostrSignerConnectionDraft::new(
+ nostr::Keys::generate().public_key(),
+ runtime.user_public_identity(),
+ ))
+ .expect("register connection");
+ let request_evaluation = manager
+ .evaluate_request(
+ &connection.connection_id,
+ radroots_nostr_connect::prelude::RadrootsNostrConnectRequestMessage::new(
+ "request-1",
+ RadrootsNostrConnectRequest::Ping,
+ ),
+ )
+ .expect("record audit");
+ runtime.record_operation_audit(&MycOperationAuditRecord::new(
+ MycOperationAuditKind::AuthReplayRestore,
+ MycOperationAuditOutcome::Restored,
+ Some(&connection.connection_id),
+ Some(request_evaluation.audit.request_id.as_str()),
+ 1,
+ 0,
+ "restored pending auth challenge after replay failure",
+ ));
+
+ let output = load_audit_output(
+ &runtime,
+ &manager,
+ Some(connection.connection_id.as_str()),
+ MycAuditScope::All,
+ )
+ .expect("load audit output");
+
+ assert_eq!(output.signer_request_audit, vec![request_evaluation.audit]);
+ assert_eq!(output.runtime_operation_audit.len(), 1);
+ assert_eq!(
+ output.runtime_operation_audit[0].operation,
+ MycOperationAuditKind::AuthReplayRestore
+ );
+ }
+}
diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs
@@ -4,7 +4,10 @@ use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use myc::control;
-use myc::{MycConfig, MycConnectionApproval, MycRuntime};
+use myc::{
+ MycConfig, MycConnectionApproval, MycOperationAuditKind, MycOperationAuditOutcome,
+ MycOperationAuditRecord, MycRuntime,
+};
use nostr::filter::MatchEventOptions;
use nostr::nips::nip44;
use nostr::nips::nip44::Version;
@@ -472,6 +475,26 @@ async fn wait_for_connect_secret_consumed(runtime: &MycRuntime) -> TestResult<()
Ok(())
}
+async fn wait_for_operation_audit_count(
+ runtime: &MycRuntime,
+ expected: usize,
+) -> TestResult<Vec<MycOperationAuditRecord>> {
+ timeout(Duration::from_secs(5), async {
+ loop {
+ let records = runtime
+ .operation_audit_store()
+ .list()
+ .expect("operation audit");
+ if records.len() >= expected {
+ return records;
+ }
+ sleep(Duration::from_millis(25)).await;
+ }
+ })
+ .await
+ .map_err(Into::into)
+}
+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn live_listener_consumes_connect_secret_only_after_successful_publish() -> TestResult<()> {
let relay = TestRelay::spawn().await?;
@@ -521,6 +544,28 @@ async fn live_listener_consumes_connect_secret_only_after_successful_publish() -
.next()
.expect("stored connection");
assert!(!initial_connection.connect_secret_is_consumed());
+ let operation_audit = wait_for_operation_audit_count(&runtime, 1).await?;
+ assert_eq!(operation_audit.len(), 1);
+ assert_eq!(
+ operation_audit[0].operation,
+ MycOperationAuditKind::ListenerResponsePublish
+ );
+ assert_eq!(
+ operation_audit[0].outcome,
+ MycOperationAuditOutcome::Rejected
+ );
+ assert_eq!(
+ operation_audit[0].connection_id.as_deref(),
+ Some(initial_connection.connection_id.as_str())
+ );
+ assert_eq!(operation_audit[0].request_id.as_deref(), Some("connect-1"));
+ assert_eq!(operation_audit[0].relay_count, 1);
+ assert_eq!(operation_audit[0].acknowledged_relay_count, 0);
+ assert!(
+ operation_audit[0]
+ .relay_outcome_summary
+ .contains("blocked by test relay")
+ );
let request_two = build_request_event(
&client_identity,
@@ -565,6 +610,7 @@ async fn live_listener_consumes_connect_secret_only_after_successful_publish() -
.len(),
1
);
+ assert_eq!(runtime.operation_audit_store().list()?.len(), 1);
let _ = shutdown_tx.send(());
listener_task.await??;
@@ -605,6 +651,27 @@ async fn connect_accept_retries_without_consuming_secret_until_publish_succeeds(
.next()
.expect("stored connection");
assert!(!stored_after_failure.connect_secret_is_consumed());
+ let operation_audit = wait_for_operation_audit_count(&runtime, 1).await?;
+ assert_eq!(
+ operation_audit[0].operation,
+ MycOperationAuditKind::ConnectAcceptPublish
+ );
+ assert_eq!(
+ operation_audit[0].outcome,
+ MycOperationAuditOutcome::Rejected
+ );
+ assert_eq!(
+ operation_audit[0].connection_id.as_deref(),
+ Some(stored_after_failure.connection_id.as_str())
+ );
+ assert!(operation_audit[0].request_id.is_some());
+ assert_eq!(operation_audit[0].relay_count, 1);
+ assert_eq!(operation_audit[0].acknowledged_relay_count, 0);
+ assert!(
+ operation_audit[0]
+ .relay_outcome_summary
+ .contains("blocked by test relay")
+ );
let accepted = control::accept_client_uri(&runtime, &client_uri).await?;
assert_eq!(accepted.response_request_id.len(), 36);
@@ -626,6 +693,30 @@ async fn connect_accept_retries_without_consuming_secret_until_publish_succeeds(
.next()
.expect("stored connection");
assert!(stored_after_success.connect_secret_is_consumed());
+ let operation_audit = wait_for_operation_audit_count(&runtime, 2).await?;
+ assert_eq!(
+ operation_audit[1].operation,
+ MycOperationAuditKind::ConnectAcceptPublish
+ );
+ assert_eq!(
+ operation_audit[1].outcome,
+ MycOperationAuditOutcome::Succeeded
+ );
+ assert_eq!(
+ operation_audit[1].connection_id.as_deref(),
+ Some(stored_after_success.connection_id.as_str())
+ );
+ assert_eq!(
+ operation_audit[1].request_id.as_deref(),
+ Some(accepted.response_request_id.as_str())
+ );
+ assert_eq!(operation_audit[1].relay_count, 1);
+ assert_eq!(operation_audit[1].acknowledged_relay_count, 1);
+ assert!(
+ operation_audit[1]
+ .relay_outcome_summary
+ .contains("1/1 relays acknowledged publish")
+ );
let consumed = control::accept_client_uri(&runtime, &client_uri)
.await
@@ -690,6 +781,47 @@ async fn auth_replay_restores_pending_request_until_publish_succeeds() -> TestRe
.authorized_at_unix,
None
);
+ let operation_audit = wait_for_operation_audit_count(&runtime, 2).await?;
+ assert_eq!(
+ operation_audit[0].operation,
+ MycOperationAuditKind::AuthReplayPublish
+ );
+ assert_eq!(
+ operation_audit[0].outcome,
+ MycOperationAuditOutcome::Rejected
+ );
+ assert_eq!(
+ operation_audit[0].connection_id.as_deref(),
+ Some(connection.connection_id.as_str())
+ );
+ assert_eq!(operation_audit[0].request_id.as_deref(), Some("auth-ping"));
+ assert_eq!(operation_audit[0].relay_count, 1);
+ assert_eq!(operation_audit[0].acknowledged_relay_count, 0);
+ assert!(
+ operation_audit[0]
+ .relay_outcome_summary
+ .contains("blocked by test relay")
+ );
+ assert_eq!(
+ operation_audit[1].operation,
+ MycOperationAuditKind::AuthReplayRestore
+ );
+ assert_eq!(
+ operation_audit[1].outcome,
+ MycOperationAuditOutcome::Restored
+ );
+ assert_eq!(
+ operation_audit[1].connection_id.as_deref(),
+ Some(connection.connection_id.as_str())
+ );
+ assert_eq!(operation_audit[1].request_id.as_deref(), Some("auth-ping"));
+ assert_eq!(operation_audit[1].relay_count, 1);
+ assert_eq!(operation_audit[1].acknowledged_relay_count, 0);
+ assert!(
+ operation_audit[1]
+ .relay_outcome_summary
+ .contains("restored pending auth challenge")
+ );
let replayed = control::authorize_auth_challenge(&runtime, &connection.connection_id).await?;
assert_eq!(replayed.replayed_request_id.as_deref(), Some("auth-ping"));
@@ -716,6 +848,27 @@ async fn auth_replay_restores_pending_request_until_publish_succeeds() -> TestRe
);
assert!(authorized.pending_request.is_none());
assert!(authorized.last_authenticated_at_unix.is_some());
+ let operation_audit = wait_for_operation_audit_count(&runtime, 3).await?;
+ assert_eq!(
+ operation_audit[2].operation,
+ MycOperationAuditKind::AuthReplayPublish
+ );
+ assert_eq!(
+ operation_audit[2].outcome,
+ MycOperationAuditOutcome::Succeeded
+ );
+ assert_eq!(
+ operation_audit[2].connection_id.as_deref(),
+ Some(connection.connection_id.as_str())
+ );
+ assert_eq!(operation_audit[2].request_id.as_deref(), Some("auth-ping"));
+ assert_eq!(operation_audit[2].relay_count, 1);
+ assert_eq!(operation_audit[2].acknowledged_relay_count, 1);
+ assert!(
+ operation_audit[2]
+ .relay_outcome_summary
+ .contains("1/1 relays acknowledged publish")
+ );
Ok(())
}