myc

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

control.rs (32226B)


      1 use std::str::FromStr;
      2 
      3 use radroots_nostr_connect::prelude::{
      4     RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest,
      5     RadrootsNostrConnectResponse, RadrootsNostrConnectUri,
      6 };
      7 use radroots_nostr_signer::prelude::{
      8     RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerBackend,
      9     RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord,
     10     RadrootsNostrSignerPublishTransition, RadrootsNostrSignerPublishWorkflowRecord,
     11     RadrootsNostrSignerRequestId, RadrootsNostrSignerWorkflowId,
     12 };
     13 use serde::Serialize;
     14 
     15 use crate::app::MycRuntime;
     16 use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord};
     17 use crate::error::MycError;
     18 use crate::outbox::{MycDeliveryOutboxKind, MycDeliveryOutboxRecord};
     19 use crate::transport::{MycNip46Handler, MycNostrTransport, MycPublishOutcome};
     20 
     21 #[derive(Debug, Serialize)]
     22 pub struct MycAuthorizedReplayOutput {
     23     pub connection: RadrootsNostrSignerConnectionRecord,
     24     pub replayed_request_id: Option<String>,
     25 }
     26 
     27 #[derive(Debug, Serialize)]
     28 pub struct MycAcceptedConnectionOutput {
     29     pub connection: RadrootsNostrSignerConnectionRecord,
     30     pub response_request_id: String,
     31     pub response_relays: Vec<String>,
     32 }
     33 
     34 pub async fn authorize_auth_challenge(
     35     runtime: &MycRuntime,
     36     connection_id: &RadrootsNostrSignerConnectionId,
     37 ) -> Result<MycAuthorizedReplayOutput, MycError> {
     38     let backend = runtime.signer_backend();
     39     let connection = backend.get_connection(connection_id)?.ok_or_else(|| {
     40         MycError::InvalidOperation(format!("connection `{connection_id}` was not found"))
     41     })?;
     42     runtime
     43         .signer_context()
     44         .policy()
     45         .ensure_authorize_auth_challenge_allowed(&connection)?;
     46     let workflow = workflow_from_transition(
     47         backend.begin_auth_replay_publish_finalization(connection_id)?,
     48         "auth replay",
     49     )?;
     50     let replayed_request_id =
     51         replay_authorized_request(runtime, &connection.connection_id, &workflow.workflow_id)
     52             .await?;
     53     let connection = runtime
     54         .signer_backend()
     55         .get_connection(connection_id)?
     56         .ok_or_else(|| {
     57             MycError::InvalidOperation(format!("connection `{connection_id}` was not found"))
     58         })?;
     59     Ok(MycAuthorizedReplayOutput {
     60         connection,
     61         replayed_request_id,
     62     })
     63 }
     64 
     65 pub async fn accept_client_uri(
     66     runtime: &MycRuntime,
     67     uri: &str,
     68 ) -> Result<MycAcceptedConnectionOutput, MycError> {
     69     let Some(transport) = runtime.transport() else {
     70         return Err(MycError::InvalidOperation(
     71             "transport.enabled must be true to accept client nostrconnect URIs".to_owned(),
     72         ));
     73     };
     74     let preferred_relays = transport.relays().to_vec();
     75     if preferred_relays.is_empty() {
     76         return Err(MycError::InvalidOperation(
     77             "transport.relays must not be empty to accept client nostrconnect URIs".to_owned(),
     78         ));
     79     }
     80 
     81     let client_uri = match RadrootsNostrConnectUri::parse(uri)? {
     82         RadrootsNostrConnectUri::Client(client_uri) => client_uri,
     83         RadrootsNostrConnectUri::Bunker(_) => {
     84             return Err(MycError::InvalidOperation(
     85                 "connect accept requires a nostrconnect:// client URI".to_owned(),
     86             ));
     87         }
     88     };
     89 
     90     let request = RadrootsNostrConnectRequest::Connect {
     91         remote_signer_public_key: runtime.signer_identity().public_key(),
     92         secret: Some(client_uri.secret.clone()),
     93         requested_permissions: client_uri.metadata.requested_permissions.clone(),
     94     };
     95     let backend = runtime.signer_backend();
     96     let Some(approval_requirement) = runtime
     97         .signer_context()
     98         .policy()
     99         .approval_requirement_for_client(&client_uri.client_public_key)
    100     else {
    101         return Err(MycError::InvalidOperation(
    102             "client public key denied by policy".to_owned(),
    103         ));
    104     };
    105     let connection = match backend.evaluate_connect_request(client_uri.client_public_key, request)? {
    106         radroots_nostr_signer::prelude::RadrootsNostrSignerConnectEvaluation::ExistingConnection(
    107             connection,
    108         ) => {
    109             if connection.connect_secret_is_consumed() {
    110                 return Err(MycError::InvalidOperation(
    111                     "connect secret has already been consumed by a successful connection"
    112                         .to_owned(),
    113                 ));
    114             }
    115             if runtime
    116                 .signer_context()
    117                 .policy()
    118                 .approval_requirement_for_client(&connection.client_public_key)
    119                 .is_none()
    120             {
    121                 return Err(MycError::InvalidOperation(
    122                     "client public key denied by policy".to_owned(),
    123                 ));
    124             }
    125             connection
    126         }
    127         radroots_nostr_signer::prelude::RadrootsNostrSignerConnectEvaluation::RegistrationRequired(
    128             proposal,
    129         ) => {
    130             let requested_permissions = runtime
    131                 .signer_context()
    132                 .policy()
    133                 .filtered_requested_permissions(&proposal.requested_permissions);
    134             let draft = proposal
    135                 .into_connection_draft(runtime.user_public_identity())
    136                 .with_requested_permissions(requested_permissions)
    137                 .with_relays(preferred_relays.clone())
    138                 .with_approval_requirement(approval_requirement);
    139             let connection = backend.register_connection(draft)?;
    140             if approval_requirement
    141                 == RadrootsNostrSignerApprovalRequirement::NotRequired
    142             {
    143                 let granted_permissions = runtime
    144                     .signer_context()
    145                     .policy()
    146                     .auto_granted_permissions(&connection.requested_permissions);
    147                 let _ = backend.set_granted_permissions(
    148                     &connection.connection_id,
    149                     granted_permissions,
    150                 )?;
    151             }
    152             Box::new(connection)
    153         }
    154     };
    155 
    156     let handler = MycNip46Handler::new(runtime.signer_context(), preferred_relays.clone());
    157     let response_request_id = RadrootsNostrSignerRequestId::new_v7().into_string();
    158     let event = handler.build_response_event(
    159         client_uri.client_public_key,
    160         response_request_id.clone(),
    161         RadrootsNostrConnectResponse::ConnectSecretEcho(client_uri.secret),
    162     )?;
    163     let response_relays = merge_relays(&client_uri.relays, &preferred_relays);
    164     let workflow = workflow_from_transition(
    165         backend.begin_connect_secret_publish_finalization(&connection.connection_id)?,
    166         "connect accept",
    167     )?;
    168     let event = match runtime
    169         .signer_identity()
    170         .sign_event_builder(event, "connect accept response")
    171     {
    172         Ok(event) => event,
    173         Err(error) => {
    174             return Err(cancel_connect_accept_workflow_on_error(
    175                 runtime,
    176                 &workflow.workflow_id,
    177                 MycError::InvalidOperation(format!(
    178                     "failed to sign connect accept response event: {error}"
    179                 )),
    180             ));
    181         }
    182     };
    183     let outbox_record = match build_control_outbox_record(
    184         MycDeliveryOutboxKind::ConnectAcceptPublish,
    185         event.clone(),
    186         &response_relays,
    187         Some(&connection.connection_id),
    188         Some(response_request_id.as_str()),
    189         Some(&workflow.workflow_id),
    190     ) {
    191         Ok(record) => record,
    192         Err(error) => {
    193             return Err(cancel_connect_accept_workflow_on_error(
    194                 runtime,
    195                 &workflow.workflow_id,
    196                 error,
    197             ));
    198         }
    199     };
    200     if let Err(error) = runtime.delivery_outbox_store().enqueue(&outbox_record) {
    201         return Err(cancel_connect_accept_workflow_on_error(
    202             runtime,
    203             &workflow.workflow_id,
    204             error,
    205         ));
    206     }
    207     let publish_outcome = match MycNostrTransport::publish_event_once(
    208         runtime.signer_identity(),
    209         &response_relays,
    210         &runtime.config().transport,
    211         "connect accept response publish",
    212         &event,
    213     )
    214     .await
    215     {
    216         Ok(outcome) => outcome,
    217         Err(error) => {
    218             let error = mark_outbox_publish_failed(runtime, &outbox_record, error);
    219             runtime.record_operation_audit(&record_publish_failure(
    220                 MycOperationAuditKind::ConnectAcceptPublish,
    221                 Some(&connection.connection_id),
    222                 Some(response_request_id.as_str()),
    223                 response_relays.len(),
    224                 &error,
    225             ));
    226             return Err(cancel_connect_accept_workflow_on_error(
    227                 runtime,
    228                 &workflow.workflow_id,
    229                 error,
    230             ));
    231         }
    232     };
    233     if let Err(error) = backend.mark_publish_workflow_published(&workflow.workflow_id) {
    234         record_post_publish_failure(
    235             runtime,
    236             MycOperationAuditKind::ConnectAcceptPublish,
    237             Some(&connection.connection_id),
    238             Some(response_request_id.as_str()),
    239             &publish_outcome,
    240             format!("failed to mark connect-accept publish workflow as published: {error}"),
    241         );
    242         return Err(error.into());
    243     }
    244     if let Err(error) = runtime
    245         .delivery_outbox_store()
    246         .mark_published_pending_finalize(&outbox_record.job_id, publish_outcome.attempt_count)
    247     {
    248         record_post_publish_failure(
    249             runtime,
    250             MycOperationAuditKind::ConnectAcceptPublish,
    251             Some(&connection.connection_id),
    252             Some(response_request_id.as_str()),
    253             &publish_outcome,
    254             format!("failed to persist connect-accept outbox published state: {error}"),
    255         );
    256         return Err(error);
    257     }
    258     if let Err(error) = backend.finalize_publish_workflow(&workflow.workflow_id) {
    259         record_post_publish_failure(
    260             runtime,
    261             MycOperationAuditKind::ConnectAcceptPublish,
    262             Some(&connection.connection_id),
    263             Some(response_request_id.as_str()),
    264             &publish_outcome,
    265             format!("failed to finalize connect-accept publish workflow: {error}"),
    266         );
    267         return Err(error.into());
    268     }
    269     if let Err(error) = runtime
    270         .delivery_outbox_store()
    271         .mark_finalized(&outbox_record.job_id)
    272     {
    273         record_post_publish_failure(
    274             runtime,
    275             MycOperationAuditKind::ConnectAcceptPublish,
    276             Some(&connection.connection_id),
    277             Some(response_request_id.as_str()),
    278             &publish_outcome,
    279             format!("failed to finalize connect-accept outbox job: {error}"),
    280         );
    281         return Err(error);
    282     }
    283     record_publish_audit(
    284         runtime,
    285         MycOperationAuditKind::ConnectAcceptPublish,
    286         MycOperationAuditOutcome::Succeeded,
    287         Some(&connection.connection_id),
    288         Some(response_request_id.as_str()),
    289         &publish_outcome,
    290     );
    291 
    292     Ok(MycAcceptedConnectionOutput {
    293         connection: backend
    294             .get_connection(&connection.connection_id)?
    295             .ok_or_else(|| {
    296                 MycError::InvalidOperation("accepted connection was not persisted".to_owned())
    297             })?,
    298         response_request_id,
    299         response_relays: response_relays.iter().map(ToString::to_string).collect(),
    300     })
    301 }
    302 
    303 pub fn parse_permission_values(
    304     values: &[String],
    305 ) -> Result<RadrootsNostrConnectPermissions, MycError> {
    306     let mut permissions = Vec::new();
    307     for value in values {
    308         for fragment in value.split(',') {
    309             let trimmed = fragment.trim();
    310             if trimmed.is_empty() {
    311                 continue;
    312             }
    313             permissions.push(RadrootsNostrConnectPermission::from_str(trimmed)?);
    314         }
    315     }
    316     permissions.sort();
    317     permissions.dedup();
    318     Ok(permissions.into())
    319 }
    320 
    321 async fn replay_authorized_request(
    322     runtime: &MycRuntime,
    323     connection_id: &RadrootsNostrSignerConnectionId,
    324     workflow_id: &RadrootsNostrSignerWorkflowId,
    325 ) -> Result<Option<String>, MycError> {
    326     let backend = runtime.signer_backend();
    327     let workflow = backend.get_publish_workflow(workflow_id)?.ok_or_else(|| {
    328         MycError::InvalidOperation(format!("publish workflow `{workflow_id}` was not found"))
    329     })?;
    330     let Some(pending_request) = workflow.pending_request.clone() else {
    331         return Ok(None);
    332     };
    333     let transport = match runtime.transport() {
    334         Some(transport) => transport,
    335         None => {
    336             let error = MycError::InvalidOperation(
    337                 "transport.enabled must be true to replay authorized requests".to_owned(),
    338             );
    339             return Err(cancel_auth_replay_workflow_on_error(
    340                 runtime,
    341                 connection_id,
    342                 workflow_id,
    343                 Some(&pending_request.request_message.id),
    344                 error,
    345             ));
    346         }
    347     };
    348     let handler = MycNip46Handler::new(runtime.signer_context(), transport.relays().to_vec());
    349     let evaluation = match backend.evaluate_auth_replay_publish_workflow(workflow_id) {
    350         Ok(evaluation) => evaluation,
    351         Err(error) => {
    352             return Err(cancel_auth_replay_workflow_on_error(
    353                 runtime,
    354                 connection_id,
    355                 workflow_id,
    356                 Some(&pending_request.request_message.id),
    357                 error.into(),
    358             ));
    359         }
    360     };
    361     let handled_outcome = match handler
    362         .handle_authorized_request_evaluation(pending_request.request_message.clone(), evaluation)
    363     {
    364         Ok(handled_outcome) => handled_outcome,
    365         Err(error) => {
    366             return Err(cancel_auth_replay_workflow_on_error(
    367                 runtime,
    368                 connection_id,
    369                 workflow_id,
    370                 Some(&pending_request.request_message.id),
    371                 error,
    372             ));
    373         }
    374     };
    375     if let Some(audit) = handled_outcome.audit.as_ref() {
    376         runtime.signer_context().record_signer_request_audit(audit);
    377     }
    378     let Some((response, _, consume_connect_secret_for)) =
    379         handled_outcome.handled_request.into_publish_parts()
    380     else {
    381         let error = MycError::InvalidOperation(
    382             "authorized auth replay did not produce a response".to_owned(),
    383         );
    384         return Err(cancel_auth_replay_workflow_on_error(
    385             runtime,
    386             connection_id,
    387             workflow_id,
    388             Some(&pending_request.request_message.id),
    389             error,
    390         ));
    391     };
    392     if consume_connect_secret_for.is_some() {
    393         return Err(cancel_auth_replay_workflow_on_error(
    394             runtime,
    395             connection_id,
    396             workflow_id,
    397             Some(&pending_request.request_message.id),
    398             MycError::InvalidOperation(
    399                 "auth replay unexpectedly requested connect-secret finalization".to_owned(),
    400             ),
    401         ));
    402     }
    403     let event = match handler.build_response_event(
    404         backend
    405             .get_connection(connection_id)?
    406             .ok_or_else(|| {
    407                 MycError::InvalidOperation(format!("connection `{connection_id}` was not found"))
    408             })?
    409             .client_public_key,
    410         pending_request.request_message.id.clone(),
    411         response,
    412     ) {
    413         Ok(event) => event,
    414         Err(error) => {
    415             return Err(cancel_auth_replay_workflow_on_error(
    416                 runtime,
    417                 connection_id,
    418                 workflow_id,
    419                 Some(&pending_request.request_message.id),
    420                 error,
    421             ));
    422         }
    423     };
    424     let connection = backend.get_connection(connection_id)?.ok_or_else(|| {
    425         MycError::InvalidOperation(format!("connection `{connection_id}` was not found"))
    426     })?;
    427     let event = match runtime
    428         .signer_identity()
    429         .sign_event_builder(event, "authorized auth replay response")
    430     {
    431         Ok(event) => event,
    432         Err(error) => {
    433             return Err(cancel_auth_replay_workflow_on_error(
    434                 runtime,
    435                 connection_id,
    436                 workflow_id,
    437                 Some(&pending_request.request_message.id),
    438                 MycError::InvalidOperation(format!(
    439                     "failed to sign authorized auth replay response event: {error}"
    440                 )),
    441             ));
    442         }
    443     };
    444     let publish_relays = if connection.relays.is_empty() {
    445         transport.relays().to_vec()
    446     } else {
    447         connection.relays.clone()
    448     };
    449     let outbox_record = match build_control_outbox_record(
    450         MycDeliveryOutboxKind::AuthReplayPublish,
    451         event.clone(),
    452         &publish_relays,
    453         Some(connection_id),
    454         Some(&pending_request.request_message.id),
    455         Some(workflow_id),
    456     ) {
    457         Ok(record) => record,
    458         Err(error) => {
    459             return Err(cancel_auth_replay_workflow_on_error(
    460                 runtime,
    461                 connection_id,
    462                 workflow_id,
    463                 Some(&pending_request.request_message.id),
    464                 error,
    465             ));
    466         }
    467     };
    468     if let Err(error) = runtime.delivery_outbox_store().enqueue(&outbox_record) {
    469         return Err(cancel_auth_replay_workflow_on_error(
    470             runtime,
    471             connection_id,
    472             workflow_id,
    473             Some(&pending_request.request_message.id),
    474             error,
    475         ));
    476     }
    477     let publish_outcome = match MycNostrTransport::publish_event_once(
    478         runtime.signer_identity(),
    479         &publish_relays,
    480         &runtime.config().transport,
    481         "authorized auth replay publish",
    482         &event,
    483     )
    484     .await
    485     {
    486         Ok(publish_outcome) => publish_outcome,
    487         Err(error) => {
    488             let error = mark_outbox_publish_failed(runtime, &outbox_record, error);
    489             runtime.record_operation_audit(&record_publish_failure(
    490                 MycOperationAuditKind::AuthReplayPublish,
    491                 Some(connection_id),
    492                 Some(pending_request.request_message.id.as_str()),
    493                 publish_relays.len(),
    494                 &error,
    495             ));
    496             return Err(cancel_auth_replay_workflow_on_error(
    497                 runtime,
    498                 connection_id,
    499                 workflow_id,
    500                 Some(&pending_request.request_message.id),
    501                 error,
    502             ));
    503         }
    504     };
    505     if let Err(error) = backend.mark_publish_workflow_published(workflow_id) {
    506         record_post_publish_failure(
    507             runtime,
    508             MycOperationAuditKind::AuthReplayPublish,
    509             Some(connection_id),
    510             Some(pending_request.request_message.id.as_str()),
    511             &publish_outcome,
    512             format!("failed to mark auth replay publish workflow as published: {error}"),
    513         );
    514         return Err(error.into());
    515     }
    516     if let Err(error) = runtime
    517         .delivery_outbox_store()
    518         .mark_published_pending_finalize(&outbox_record.job_id, publish_outcome.attempt_count)
    519     {
    520         record_post_publish_failure(
    521             runtime,
    522             MycOperationAuditKind::AuthReplayPublish,
    523             Some(connection_id),
    524             Some(pending_request.request_message.id.as_str()),
    525             &publish_outcome,
    526             format!("failed to persist auth replay outbox published state: {error}"),
    527         );
    528         return Err(error);
    529     }
    530     if let Err(error) = backend.finalize_publish_workflow(workflow_id) {
    531         record_post_publish_failure(
    532             runtime,
    533             MycOperationAuditKind::AuthReplayPublish,
    534             Some(connection_id),
    535             Some(pending_request.request_message.id.as_str()),
    536             &publish_outcome,
    537             format!("failed to finalize auth replay publish workflow: {error}"),
    538         );
    539         return Err(error.into());
    540     }
    541     if let Err(error) = runtime
    542         .delivery_outbox_store()
    543         .mark_finalized(&outbox_record.job_id)
    544     {
    545         record_post_publish_failure(
    546             runtime,
    547             MycOperationAuditKind::AuthReplayPublish,
    548             Some(connection_id),
    549             Some(pending_request.request_message.id.as_str()),
    550             &publish_outcome,
    551             format!("failed to finalize auth replay outbox job: {error}"),
    552         );
    553         return Err(error);
    554     }
    555     record_publish_audit(
    556         runtime,
    557         MycOperationAuditKind::AuthReplayPublish,
    558         MycOperationAuditOutcome::Succeeded,
    559         Some(connection_id),
    560         Some(pending_request.request_message.id.as_str()),
    561         &publish_outcome,
    562     );
    563     Ok(Some(pending_request.request_message.id.clone()))
    564 }
    565 
    566 fn cancel_auth_replay_workflow_on_error(
    567     runtime: &MycRuntime,
    568     connection_id: &RadrootsNostrSignerConnectionId,
    569     workflow_id: &RadrootsNostrSignerWorkflowId,
    570     request_id: Option<&str>,
    571     error: MycError,
    572 ) -> MycError {
    573     let summary = publish_failure_summary(&error);
    574     match runtime
    575         .signer_backend()
    576         .cancel_publish_workflow(workflow_id)
    577         .map_err(MycError::from)
    578     {
    579         Ok(_) => {
    580             let mut record = MycOperationAuditRecord::new(
    581                 MycOperationAuditKind::AuthReplayRestore,
    582                 MycOperationAuditOutcome::Restored,
    583                 Some(connection_id),
    584                 request_id,
    585                 error
    586                     .publish_rejection_counts()
    587                     .map(|(relay_count, _)| relay_count)
    588                     .unwrap_or_default(),
    589                 error
    590                     .publish_rejection_counts()
    591                     .map(|(_, acknowledged)| acknowledged)
    592                     .unwrap_or_default(),
    593                 format!("preserved pending auth challenge after replay failure: {summary}"),
    594             );
    595             if let (
    596                 Some(delivery_policy),
    597                 Some(required_acknowledged_relay_count),
    598                 Some(attempt_count),
    599             ) = (
    600                 error.publish_delivery_policy(),
    601                 error.publish_required_acknowledged_relay_count(),
    602                 error.publish_attempt_count(),
    603             ) {
    604                 record = record.with_delivery_details(
    605                     delivery_policy,
    606                     required_acknowledged_relay_count,
    607                     attempt_count,
    608                 );
    609             }
    610             runtime.record_operation_audit(&record);
    611             error
    612         }
    613         Err(restore_error) => MycError::InvalidOperation(format!(
    614             "{error}; additionally failed to cancel auth replay publish workflow: {restore_error}"
    615         )),
    616     }
    617 }
    618 
    619 fn cancel_connect_accept_workflow_on_error(
    620     runtime: &MycRuntime,
    621     workflow_id: &RadrootsNostrSignerWorkflowId,
    622     error: MycError,
    623 ) -> MycError {
    624     match runtime
    625         .signer_backend()
    626         .cancel_publish_workflow(workflow_id)
    627         .map(|_| ())
    628         .map_err(MycError::from)
    629     {
    630         Ok(()) => error,
    631         Err(cancel_error) => MycError::InvalidOperation(format!(
    632             "{error}; additionally failed to cancel connect-accept publish workflow: {cancel_error}"
    633         )),
    634     }
    635 }
    636 
    637 fn workflow_from_transition(
    638     transition: RadrootsNostrSignerPublishTransition,
    639     operation: &str,
    640 ) -> Result<RadrootsNostrSignerPublishWorkflowRecord, MycError> {
    641     transition.workflow().cloned().ok_or_else(|| {
    642         MycError::InvalidOperation(format!(
    643             "{operation} publish workflow did not return a workflow record"
    644         ))
    645     })
    646 }
    647 
    648 fn build_control_outbox_record(
    649     kind: MycDeliveryOutboxKind,
    650     event: radroots_nostr::prelude::RadrootsNostrEvent,
    651     relay_urls: &[nostr::RelayUrl],
    652     connection_id: Option<&RadrootsNostrSignerConnectionId>,
    653     request_id: Option<&str>,
    654     workflow_id: Option<&RadrootsNostrSignerWorkflowId>,
    655 ) -> Result<MycDeliveryOutboxRecord, MycError> {
    656     let relay_urls = relay_urls.to_vec();
    657     let mut record = MycDeliveryOutboxRecord::new(kind, event, relay_urls)?;
    658     if let Some(connection_id) = connection_id {
    659         record = record.with_connection_id(connection_id);
    660     }
    661     if let Some(request_id) = request_id {
    662         record = record.with_request_id(request_id.to_owned());
    663     }
    664     if let Some(workflow_id) = workflow_id {
    665         record = record.with_signer_publish_workflow_id(workflow_id);
    666     }
    667     Ok(record)
    668 }
    669 
    670 fn mark_outbox_publish_failed(
    671     runtime: &MycRuntime,
    672     outbox_record: &MycDeliveryOutboxRecord,
    673     error: MycError,
    674 ) -> MycError {
    675     let publish_attempt_count = error.publish_attempt_count().unwrap_or_default();
    676     let summary = publish_failure_summary(&error);
    677     match runtime.delivery_outbox_store().mark_failed(
    678         &outbox_record.job_id,
    679         publish_attempt_count,
    680         &summary,
    681     ) {
    682         Ok(_) => error,
    683         Err(outbox_error) => MycError::InvalidOperation(format!(
    684             "{error}; additionally failed to persist publish failure to the delivery outbox: {outbox_error}"
    685         )),
    686     }
    687 }
    688 
    689 fn record_publish_audit(
    690     runtime: &MycRuntime,
    691     operation: MycOperationAuditKind,
    692     outcome: MycOperationAuditOutcome,
    693     connection_id: Option<&RadrootsNostrSignerConnectionId>,
    694     request_id: Option<&str>,
    695     publish_outcome: &MycPublishOutcome,
    696 ) {
    697     runtime.record_operation_audit(
    698         &MycOperationAuditRecord::new(
    699             operation,
    700             outcome,
    701             connection_id,
    702             request_id,
    703             publish_outcome.relay_count,
    704             publish_outcome.acknowledged_relay_count,
    705             publish_outcome.relay_outcome_summary.clone(),
    706         )
    707         .with_delivery_details(
    708             publish_outcome.delivery_policy,
    709             publish_outcome.required_acknowledged_relay_count,
    710             publish_outcome.attempt_count,
    711         ),
    712     );
    713 }
    714 
    715 fn record_post_publish_failure(
    716     runtime: &MycRuntime,
    717     operation: MycOperationAuditKind,
    718     connection_id: Option<&RadrootsNostrSignerConnectionId>,
    719     request_id: Option<&str>,
    720     publish_outcome: &MycPublishOutcome,
    721     summary: impl Into<String>,
    722 ) {
    723     runtime.record_operation_audit(
    724         &MycOperationAuditRecord::new(
    725             operation,
    726             MycOperationAuditOutcome::Rejected,
    727             connection_id,
    728             request_id,
    729             publish_outcome.relay_count,
    730             publish_outcome.acknowledged_relay_count,
    731             summary.into(),
    732         )
    733         .with_delivery_details(
    734             publish_outcome.delivery_policy,
    735             publish_outcome.required_acknowledged_relay_count,
    736             publish_outcome.attempt_count,
    737         ),
    738     );
    739 }
    740 
    741 fn publish_failure_summary(error: &MycError) -> String {
    742     error
    743         .publish_rejection_details()
    744         .map(ToOwned::to_owned)
    745         .unwrap_or_else(|| error.to_string())
    746 }
    747 
    748 fn record_publish_failure(
    749     operation: MycOperationAuditKind,
    750     connection_id: Option<&RadrootsNostrSignerConnectionId>,
    751     request_id: Option<&str>,
    752     relay_count: usize,
    753     error: &MycError,
    754 ) -> MycOperationAuditRecord {
    755     let mut record = MycOperationAuditRecord::new(
    756         operation,
    757         MycOperationAuditOutcome::Rejected,
    758         connection_id,
    759         request_id,
    760         relay_count,
    761         error
    762             .publish_rejection_counts()
    763             .map(|(_, acknowledged)| acknowledged)
    764             .unwrap_or_default(),
    765         publish_failure_summary(error),
    766     );
    767     if let (Some(delivery_policy), Some(required_acknowledged_relay_count), Some(attempt_count)) = (
    768         error.publish_delivery_policy(),
    769         error.publish_required_acknowledged_relay_count(),
    770         error.publish_attempt_count(),
    771     ) {
    772         record = record.with_delivery_details(
    773             delivery_policy,
    774             required_acknowledged_relay_count,
    775             attempt_count,
    776         );
    777     }
    778     record
    779 }
    780 
    781 fn merge_relays(
    782     primary: &[nostr::RelayUrl],
    783     secondary: &[nostr::RelayUrl],
    784 ) -> Vec<nostr::RelayUrl> {
    785     let mut relays = primary.to_vec();
    786     relays.extend_from_slice(secondary);
    787     relays.sort_by(|left, right| left.as_str().cmp(right.as_str()));
    788     relays.dedup_by(|left, right| left.as_str() == right.as_str());
    789     relays
    790 }
    791 
    792 #[cfg(test)]
    793 mod tests {
    794     use super::{accept_client_uri, authorize_auth_challenge};
    795     use crate::app::MycRuntime;
    796     use crate::config::{MycConfig, MycConnectionApproval};
    797     use radroots_identity::RadrootsIdentity;
    798     use radroots_nostr_connect::prelude::{
    799         RadrootsNostrConnectClientMetadata, RadrootsNostrConnectClientUri, RadrootsNostrConnectUri,
    800     };
    801     use std::path::PathBuf;
    802     use std::thread;
    803     use std::time::Duration;
    804 
    805     fn write_identity(path: &std::path::Path, secret_key: &str) {
    806         let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity");
    807         crate::identity_files::store_encrypted_identity(path, &identity).expect("save identity");
    808     }
    809 
    810     fn runtime_with_config<F>(approval: MycConnectionApproval, configure: F) -> MycRuntime
    811     where
    812         F: FnOnce(&mut MycConfig),
    813     {
    814         let temp = tempfile::tempdir().expect("tempdir").keep();
    815         let mut config = MycConfig::default();
    816         config.paths.state_dir = PathBuf::from(&temp).join("state");
    817         config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json");
    818         config.paths.user_identity_path = PathBuf::from(&temp).join("user.json");
    819         config.policy.connection_approval = approval;
    820         config.transport.enabled = true;
    821         config.transport.relays = vec!["ws://127.0.0.1:65500".to_owned()];
    822         configure(&mut config);
    823         write_identity(
    824             &config.paths.signer_identity_path,
    825             "1111111111111111111111111111111111111111111111111111111111111111",
    826         );
    827         write_identity(
    828             &config.paths.user_identity_path,
    829             "2222222222222222222222222222222222222222222222222222222222222222",
    830         );
    831         MycRuntime::bootstrap(config).expect("runtime")
    832     }
    833 
    834     #[tokio::test(flavor = "current_thread")]
    835     async fn authorize_auth_challenge_rejects_expired_pending_challenge() {
    836         let runtime = runtime_with_config(MycConnectionApproval::ExplicitUser, |config| {
    837             config.policy.auth_pending_ttl_secs = 1;
    838         });
    839         let manager = runtime.signer_manager().expect("manager");
    840         let connection = manager
    841             .register_connection(
    842                 radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionDraft::new(
    843                     nostr::Keys::generate().public_key(),
    844                     runtime.user_public_identity(),
    845                 ),
    846             )
    847             .expect("register connection");
    848         manager
    849             .require_auth_challenge(&connection.connection_id, "https://auth.example")
    850             .expect("require auth challenge");
    851 
    852         thread::sleep(Duration::from_secs(2));
    853 
    854         let error = authorize_auth_challenge(&runtime, &connection.connection_id)
    855             .await
    856             .expect_err("expired auth challenge should be rejected");
    857         assert!(error.to_string().contains("auth challenge expired"));
    858     }
    859 
    860     #[tokio::test(flavor = "current_thread")]
    861     async fn accept_client_uri_rejects_denied_client_pubkeys() {
    862         let denied_identity = RadrootsIdentity::from_secret_key_str(
    863             "3333333333333333333333333333333333333333333333333333333333333333",
    864         )
    865         .expect("identity");
    866         let runtime = runtime_with_config(MycConnectionApproval::ExplicitUser, |config| {
    867             config.policy.denied_client_pubkeys = vec![denied_identity.public_key().to_hex()];
    868         });
    869         let uri = RadrootsNostrConnectUri::Client(RadrootsNostrConnectClientUri {
    870             client_public_key: denied_identity.public_key(),
    871             relays: vec![nostr::RelayUrl::parse("ws://127.0.0.1:65500").expect("relay")],
    872             secret: "client-secret".to_owned(),
    873             metadata: RadrootsNostrConnectClientMetadata::default(),
    874         })
    875         .to_string();
    876 
    877         let error = accept_client_uri(&runtime, &uri)
    878             .await
    879             .expect_err("denied client should be rejected");
    880         assert!(
    881             error
    882                 .to_string()
    883                 .contains("client public key denied by policy")
    884         );
    885     }
    886 }