commit 7f4d506df78500f38b130eb60dab8274c729ab64
parent fb8bf4344e8a1d7395182dc1f97e2d21a82440f7
Author: triesap <tyson@radroots.org>
Date: Sun, 22 Mar 2026 01:56:00 +0000
app: record publish and replay operation audit
- record listener publish rejections with connection and request context
- record connect accept and auth replay publish outcomes with relay summaries
- restore pending auth challenges with a durable operation audit trail
- add structured tracing fields for operation, request, connection, and relay results
Diffstat:
| M | src/control.rs | | | 124 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------- |
| M | src/transport.rs | | | 62 | ++++++++++++++++++++++++++++++++++++++++++++++---------------- |
| M | src/transport/nip46.rs | | | 137 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------- |
3 files changed, 259 insertions(+), 64 deletions(-)
diff --git a/src/control.rs b/src/control.rs
@@ -12,8 +12,9 @@ use radroots_nostr_signer::prelude::{
use serde::Serialize;
use crate::app::MycRuntime;
+use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord};
use crate::error::MycError;
-use crate::transport::{MycNip46Handler, MycNostrTransport};
+use crate::transport::{MycNip46Handler, MycNostrTransport, MycPublishOutcome};
#[derive(Debug, Serialize)]
pub struct MycAuthorizedReplayOutput {
@@ -113,13 +114,39 @@ pub async fn accept_client_uri(
RadrootsNostrConnectResponse::ConnectSecretEcho(client_uri.secret),
)?;
let response_relays = merge_relays(&client_uri.relays, &preferred_relays);
- MycNostrTransport::publish_once(
+ let publish_outcome = match MycNostrTransport::publish_once(
runtime.signer_identity(),
&response_relays,
transport.connect_timeout_secs(),
event,
)
- .await?;
+ .await
+ {
+ Ok(outcome) => outcome,
+ Err(error) => {
+ runtime.record_operation_audit(&MycOperationAuditRecord::new(
+ MycOperationAuditKind::ConnectAcceptPublish,
+ MycOperationAuditOutcome::Rejected,
+ Some(&connection.connection_id),
+ Some(response_request_id.as_str()),
+ response_relays.len(),
+ error
+ .publish_rejection_counts()
+ .map(|(_, acknowledged)| acknowledged)
+ .unwrap_or_default(),
+ publish_failure_summary(&error),
+ ));
+ return Err(error);
+ }
+ };
+ record_publish_audit(
+ runtime,
+ MycOperationAuditKind::ConnectAcceptPublish,
+ MycOperationAuditOutcome::Succeeded,
+ Some(&connection.connection_id),
+ Some(response_request_id.as_str()),
+ &publish_outcome,
+ );
let _ = manager.mark_connect_secret_consumed(&connection.connection_id)?;
Ok(MycAcceptedConnectionOutput {
@@ -190,7 +217,8 @@ async fn replay_authorized_request(
));
}
};
- let Some((response, consume_connect_secret_for)) = handled_request.into_publish_parts() else {
+ let Some((response, _, consume_connect_secret_for)) = handled_request.into_publish_parts()
+ else {
let error = MycError::InvalidOperation(
"authorized auth replay did not produce a response".to_owned(),
);
@@ -221,7 +249,7 @@ async fn replay_authorized_request(
} else {
outcome.connection.relays.clone()
};
- if let Err(error) = MycNostrTransport::publish_once(
+ let publish_outcome = match MycNostrTransport::publish_once(
runtime.signer_identity(),
&publish_relays,
transport.connect_timeout_secs(),
@@ -229,13 +257,36 @@ async fn replay_authorized_request(
)
.await
{
- return Err(restore_pending_auth_challenge_on_error(
- runtime,
- &outcome.connection.connection_id,
- pending_request,
- error,
- ));
- }
+ Ok(publish_outcome) => publish_outcome,
+ Err(error) => {
+ runtime.record_operation_audit(&MycOperationAuditRecord::new(
+ MycOperationAuditKind::AuthReplayPublish,
+ MycOperationAuditOutcome::Rejected,
+ Some(&outcome.connection.connection_id),
+ Some(pending_request.request_message.id.as_str()),
+ publish_relays.len(),
+ error
+ .publish_rejection_counts()
+ .map(|(_, acknowledged)| acknowledged)
+ .unwrap_or_default(),
+ publish_failure_summary(&error),
+ ));
+ return Err(restore_pending_auth_challenge_on_error(
+ runtime,
+ &outcome.connection.connection_id,
+ pending_request,
+ error,
+ ));
+ }
+ };
+ record_publish_audit(
+ runtime,
+ MycOperationAuditKind::AuthReplayPublish,
+ MycOperationAuditOutcome::Succeeded,
+ Some(&outcome.connection.connection_id),
+ Some(pending_request.request_message.id.as_str()),
+ &publish_outcome,
+ );
if let Some(connection_id) = consume_connect_secret_for {
runtime
.signer_manager()?
@@ -250,18 +301,63 @@ fn restore_pending_auth_challenge_on_error(
pending_request: RadrootsNostrSignerPendingRequest,
error: MycError,
) -> MycError {
+ let summary = publish_failure_summary(&error);
+ let request_id = pending_request.request_message.id.clone();
match runtime.signer_manager().and_then(|manager| {
manager
- .restore_pending_auth_challenge(connection_id, pending_request)
+ .restore_pending_auth_challenge(connection_id, pending_request.clone())
.map_err(Into::into)
}) {
- Ok(_) => error,
+ Ok(_) => {
+ runtime.record_operation_audit(&MycOperationAuditRecord::new(
+ MycOperationAuditKind::AuthReplayRestore,
+ MycOperationAuditOutcome::Restored,
+ Some(connection_id),
+ Some(request_id.as_str()),
+ error
+ .publish_rejection_counts()
+ .map(|(relay_count, _)| relay_count)
+ .unwrap_or_default(),
+ error
+ .publish_rejection_counts()
+ .map(|(_, acknowledged)| acknowledged)
+ .unwrap_or_default(),
+ format!("restored pending auth challenge after replay failure: {summary}"),
+ ));
+ error
+ }
Err(restore_error) => MycError::InvalidOperation(format!(
"{error}; additionally failed to restore pending auth challenge: {restore_error}"
)),
}
}
+fn record_publish_audit(
+ runtime: &MycRuntime,
+ operation: MycOperationAuditKind,
+ outcome: MycOperationAuditOutcome,
+ connection_id: Option<&RadrootsNostrSignerConnectionId>,
+ request_id: Option<&str>,
+ publish_outcome: &MycPublishOutcome,
+) {
+ runtime.record_operation_audit(&MycOperationAuditRecord::new(
+ operation,
+ outcome,
+ connection_id,
+ request_id,
+ publish_outcome.relay_count,
+ publish_outcome.acknowledged_relay_count,
+ publish_outcome.relay_outcome_summary.clone(),
+ ));
+}
+
+fn publish_failure_summary(error: &MycError) -> String {
+ error
+ .publish_rejection_details()
+ .map(ToOwned::to_owned)
+ .unwrap_or_else(|| error.to_string())
+}
+
fn merge_relays(
primary: &[nostr::RelayUrl],
secondary: &[nostr::RelayUrl],
diff --git a/src/transport.rs b/src/transport.rs
@@ -26,6 +26,13 @@ pub struct MycTransportSnapshot {
pub connect_timeout_secs: u64,
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MycPublishOutcome {
+ pub relay_count: usize,
+ pub acknowledged_relay_count: usize,
+ pub relay_outcome_summary: String,
+}
+
impl MycNostrTransport {
pub fn bootstrap(
config: &MycTransportConfig,
@@ -70,7 +77,7 @@ impl MycNostrTransport {
relays: &[RadrootsNostrRelayUrl],
connect_timeout_secs: u64,
event: RadrootsNostrEventBuilder,
- ) -> Result<(), MycError> {
+ ) -> Result<MycPublishOutcome, MycError> {
if relays.is_empty() {
return Err(MycError::InvalidOperation(
"cannot publish without at least one relay".to_owned(),
@@ -86,8 +93,7 @@ impl MycNostrTransport {
.wait_for_connection(Duration::from_secs(connect_timeout_secs))
.await;
let output = client.send_event_builder(event).await?;
- let _ = ensure_publish_confirmed(output, "one-shot Nostr publish")?;
- Ok(())
+ ensure_publish_confirmed(output, "one-shot Nostr publish")
}
pub fn snapshot(&self) -> MycTransportSnapshot {
@@ -102,29 +108,53 @@ impl MycNostrTransport {
pub(crate) fn ensure_publish_confirmed<T>(
output: RadrootsNostrOutput<T>,
operation: &str,
-) -> Result<RadrootsNostrOutput<T>, MycError>
+) -> Result<MycPublishOutcome, MycError>
where
T: std::fmt::Debug,
{
+ let relay_count = output.success.len() + output.failed.len();
+ let acknowledged_relay_count = output.success.len();
+ let relay_outcome_summary = summarize_publish_output(&output);
+
if !output.success.is_empty() {
- return Ok(output);
+ return Ok(MycPublishOutcome {
+ relay_count,
+ acknowledged_relay_count,
+ relay_outcome_summary,
+ });
}
- let details = if output.failed.is_empty() {
- "no relay acknowledged the publish".to_owned()
- } else {
- output
+ Err(MycError::PublishRejected {
+ operation: operation.to_owned(),
+ relay_count,
+ acknowledged_relay_count,
+ details: relay_outcome_summary,
+ })
+}
+
+fn summarize_publish_output<T>(output: &RadrootsNostrOutput<T>) -> String
+where
+ T: std::fmt::Debug,
+{
+ let relay_count = output.success.len() + output.failed.len();
+ let acknowledged_relay_count = output.success.len();
+ if relay_count == 0 {
+ return "no relay acknowledged the publish".to_owned();
+ }
+
+ let mut summary =
+ format!("{acknowledged_relay_count}/{relay_count} relays acknowledged publish");
+ if !output.failed.is_empty() {
+ let failures = output
.failed
.iter()
.map(|(relay, error)| format!("{relay}: {error}"))
.collect::<Vec<_>>()
- .join("; ")
- };
-
- Err(MycError::PublishRejected {
- operation: operation.to_owned(),
- details,
- })
+ .join("; ");
+ summary.push_str("; failures: ");
+ summary.push_str(&failures);
+ }
+ summary
}
impl MycTransportSnapshot {
diff --git a/src/transport/nip46.rs b/src/transport/nip46.rs
@@ -20,6 +20,7 @@ use radroots_nostr_signer::prelude::{
use tokio::sync::broadcast;
use crate::app::MycSignerContext;
+use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord};
use crate::error::MycError;
use crate::transport::{MycNostrTransport, ensure_publish_confirmed};
@@ -38,6 +39,7 @@ pub struct MycNip46Service {
pub(crate) enum MycNip46HandledRequest {
Respond {
response: RadrootsNostrConnectResponse,
+ connection_id: Option<RadrootsNostrSignerConnectionId>,
consume_connect_secret_for: Option<RadrootsNostrSignerConnectionId>,
},
Ignore,
@@ -203,20 +205,28 @@ impl MycNip46Handler {
let evaluation = manager.evaluate_request(&connection.connection_id, request_message)?;
match evaluation.action {
- RadrootsNostrSignerRequestAction::Denied { reason } => Ok(
- MycNip46HandledRequest::respond(RadrootsNostrConnectResponse::Error {
- result: None,
- error: reason,
- }),
- ),
+ RadrootsNostrSignerRequestAction::Denied { reason } => {
+ Ok(MycNip46HandledRequest::respond_for_connection(
+ Some(connection.connection_id.clone()),
+ RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: reason,
+ },
+ ))
+ }
RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => {
- Ok(MycNip46HandledRequest::respond(
+ Ok(MycNip46HandledRequest::respond_for_connection(
+ Some(connection.connection_id.clone()),
RadrootsNostrConnectResponse::AuthUrl(auth_challenge.auth_url),
))
}
RadrootsNostrSignerRequestAction::Allowed { response_hint, .. } => {
- response_from_hint(&evaluation.connection, response_hint)
- .map(MycNip46HandledRequest::respond)
+ response_from_hint(&evaluation.connection, response_hint).map(|response| {
+ MycNip46HandledRequest::respond_for_connection(
+ Some(evaluation.connection.connection_id.clone()),
+ response,
+ )
+ })
}
}
}
@@ -236,20 +246,29 @@ impl MycNip46Handler {
let evaluation = manager.evaluate_request(&connection.connection_id, request_message)?;
match evaluation.action {
- RadrootsNostrSignerRequestAction::Denied { reason } => Ok(
- MycNip46HandledRequest::respond(RadrootsNostrConnectResponse::Error {
- result: None,
- error: reason,
- }),
- ),
+ RadrootsNostrSignerRequestAction::Denied { reason } => {
+ Ok(MycNip46HandledRequest::respond_for_connection(
+ Some(connection.connection_id.clone()),
+ RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: reason,
+ },
+ ))
+ }
RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => {
- Ok(MycNip46HandledRequest::respond(
+ Ok(MycNip46HandledRequest::respond_for_connection(
+ Some(connection.connection_id.clone()),
RadrootsNostrConnectResponse::AuthUrl(auth_challenge.auth_url),
))
}
- RadrootsNostrSignerRequestAction::Allowed { .. } => self
- .sign_event_response(unsigned_event)
- .map(MycNip46HandledRequest::respond),
+ RadrootsNostrSignerRequestAction::Allowed { .. } => {
+ self.sign_event_response(unsigned_event).map(|response| {
+ MycNip46HandledRequest::respond_for_connection(
+ Some(connection.connection_id.clone()),
+ response,
+ )
+ })
+ }
}
}
@@ -268,20 +287,29 @@ impl MycNip46Handler {
let evaluation = manager.evaluate_request(&connection.connection_id, request_message)?;
match evaluation.action {
- RadrootsNostrSignerRequestAction::Denied { reason } => Ok(
- MycNip46HandledRequest::respond(RadrootsNostrConnectResponse::Error {
- result: None,
- error: reason,
- }),
- ),
+ RadrootsNostrSignerRequestAction::Denied { reason } => {
+ Ok(MycNip46HandledRequest::respond_for_connection(
+ Some(connection.connection_id.clone()),
+ RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: reason,
+ },
+ ))
+ }
RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => {
- Ok(MycNip46HandledRequest::respond(
+ Ok(MycNip46HandledRequest::respond_for_connection(
+ Some(connection.connection_id.clone()),
RadrootsNostrConnectResponse::AuthUrl(auth_challenge.auth_url),
))
}
- RadrootsNostrSignerRequestAction::Allowed { .. } => self
- .crypto_response(request)
- .map(MycNip46HandledRequest::respond),
+ RadrootsNostrSignerRequestAction::Allowed { .. } => {
+ self.crypto_response(request).map(|response| {
+ MycNip46HandledRequest::respond_for_connection(
+ Some(connection.connection_id.clone()),
+ response,
+ )
+ })
+ }
}
}
@@ -454,7 +482,8 @@ impl MycNip46Service {
})
}
};
- let Some((response, consume_connect_secret_for)) = handled_request.into_publish_parts()
+ let Some((response, connection_id, consume_connect_secret_for)) =
+ handled_request.into_publish_parts()
else {
tracing::debug!(
request_id = %request_id,
@@ -466,7 +495,7 @@ impl MycNip46Service {
let response_event =
self.handler
- .build_response_event(event.pubkey, request_id, response)?;
+ .build_response_event(event.pubkey, request_id.as_str(), response)?;
let publish_output = match self
.transport
.client()
@@ -475,13 +504,42 @@ impl MycNip46Service {
{
Ok(output) => output,
Err(error) => {
- tracing::warn!(error = %error, "failed to publish NIP-46 response");
+ self.handler
+ .signer
+ .record_operation_audit(&MycOperationAuditRecord::new(
+ MycOperationAuditKind::ListenerResponsePublish,
+ MycOperationAuditOutcome::Rejected,
+ connection_id.as_ref(),
+ Some(request_id.as_str()),
+ self.transport.relays().len(),
+ 0,
+ error.to_string(),
+ ));
continue;
}
};
if let Err(error) = ensure_publish_confirmed(publish_output, "NIP-46 response publish")
{
- tracing::warn!(error = %error, "failed to publish NIP-46 response");
+ self.handler
+ .signer
+ .record_operation_audit(&MycOperationAuditRecord::new(
+ MycOperationAuditKind::ListenerResponsePublish,
+ MycOperationAuditOutcome::Rejected,
+ connection_id.as_ref(),
+ Some(request_id.as_str()),
+ error
+ .publish_rejection_counts()
+ .map(|(relay_count, _)| relay_count)
+ .unwrap_or(self.transport.relays().len()),
+ error
+ .publish_rejection_counts()
+ .map(|(_, acknowledged)| acknowledged)
+ .unwrap_or_default(),
+ error
+ .publish_rejection_details()
+ .map(ToOwned::to_owned)
+ .unwrap_or_else(|| error.to_string()),
+ ));
continue;
}
if let Some(connection_id) = consume_connect_secret_for {
@@ -504,8 +562,16 @@ impl MycNip46Service {
impl MycNip46HandledRequest {
fn respond(response: RadrootsNostrConnectResponse) -> Self {
+ Self::respond_for_connection(None, response)
+ }
+
+ fn respond_for_connection(
+ connection_id: Option<RadrootsNostrSignerConnectionId>,
+ response: RadrootsNostrConnectResponse,
+ ) -> Self {
Self::Respond {
response,
+ connection_id,
consume_connect_secret_for: None,
}
}
@@ -515,12 +581,14 @@ impl MycNip46HandledRequest {
) -> Option<(
RadrootsNostrConnectResponse,
Option<RadrootsNostrSignerConnectionId>,
+ Option<RadrootsNostrSignerConnectionId>,
)> {
match self {
Self::Respond {
response,
+ connection_id,
consume_connect_secret_for,
- } => Some((response, consume_connect_secret_for)),
+ } => Some((response, connection_id, consume_connect_secret_for)),
Self::Ignore => None,
}
}
@@ -540,6 +608,7 @@ fn connect_response_outcome(
let consume_connect_secret_for = secret.as_ref().map(|_| connection.connection_id.clone());
MycNip46HandledRequest::Respond {
response: connect_response(secret),
+ connection_id: Some(connection.connection_id.clone()),
consume_connect_secret_for,
}
}