commit 79f2622b92270919165b21b6f2a7ce2ea6edc155
parent bf5b8ccecfc7f2565394de75fa64306441fa766e
Author: triesap <tyson@radroots.org>
Date: Thu, 26 Mar 2026 18:04:34 +0000
nostr-signer: persist publish finalization workflows
Diffstat:
9 files changed, 924 insertions(+), 17 deletions(-)
diff --git a/crates/nostr-signer/migrations/0001_publish_workflows.down.sql b/crates/nostr-signer/migrations/0001_publish_workflows.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS signer_publish_workflow;
diff --git a/crates/nostr-signer/migrations/0001_publish_workflows.up.sql b/crates/nostr-signer/migrations/0001_publish_workflows.up.sql
@@ -0,0 +1,16 @@
+CREATE TABLE IF NOT EXISTS signer_publish_workflow (
+ workflow_id TEXT PRIMARY KEY,
+ connection_id TEXT NOT NULL REFERENCES signer_connection (connection_id) ON DELETE CASCADE,
+ kind TEXT NOT NULL,
+ state TEXT NOT NULL,
+ pending_request_json TEXT,
+ authorized_at_unix INTEGER,
+ created_at_unix INTEGER NOT NULL,
+ updated_at_unix INTEGER NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS signer_publish_workflow_connection_id_idx
+ON signer_publish_workflow (connection_id);
+
+CREATE INDEX IF NOT EXISTS signer_publish_workflow_state_idx
+ON signer_publish_workflow (state);
diff --git a/crates/nostr-signer/src/error.rs b/crates/nostr-signer/src/error.rs
@@ -36,6 +36,12 @@ pub enum RadrootsNostrSignerError {
#[error("invalid request id `{0}`")]
InvalidRequestId(String),
+
+ #[error("invalid workflow id `{0}`")]
+ InvalidWorkflowId(String),
+
+ #[error("publish workflow not found: {0}")]
+ PublishWorkflowNotFound(String),
}
impl From<radroots_runtime::RuntimeJsonError> for RadrootsNostrSignerError {
diff --git a/crates/nostr-signer/src/lib.rs b/crates/nostr-signer/src/lib.rs
@@ -34,9 +34,11 @@ pub mod prelude {
RadrootsNostrSignerConnectSecretHash, RadrootsNostrSignerConnectionDraft,
RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord,
RadrootsNostrSignerConnectionStatus, RadrootsNostrSignerPendingRequest,
- RadrootsNostrSignerPermissionGrant, RadrootsNostrSignerRequestAuditRecord,
- RadrootsNostrSignerRequestDecision, RadrootsNostrSignerRequestId,
- RadrootsNostrSignerSecretDigestAlgorithm, RadrootsNostrSignerStoreState,
+ RadrootsNostrSignerPermissionGrant, RadrootsNostrSignerPublishWorkflowKind,
+ RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerPublishWorkflowState,
+ RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestDecision,
+ RadrootsNostrSignerRequestId, RadrootsNostrSignerSecretDigestAlgorithm,
+ RadrootsNostrSignerStoreState, RadrootsNostrSignerWorkflowId,
};
#[cfg(feature = "native")]
pub use crate::sqlite::RadrootsNostrSignerSqliteDb;
diff --git a/crates/nostr-signer/src/manager.rs b/crates/nostr-signer/src/manager.rs
@@ -12,9 +12,10 @@ use crate::model::{
RadrootsNostrSignerConnectSecretHash, RadrootsNostrSignerConnectionDraft,
RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord,
RadrootsNostrSignerConnectionStatus, RadrootsNostrSignerPendingRequest,
- RadrootsNostrSignerPermissionGrant, RadrootsNostrSignerRequestAuditRecord,
- RadrootsNostrSignerRequestDecision, RadrootsNostrSignerRequestId,
- RadrootsNostrSignerStoreState,
+ RadrootsNostrSignerPermissionGrant, RadrootsNostrSignerPublishWorkflowKind,
+ RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerPublishWorkflowState,
+ RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestDecision,
+ RadrootsNostrSignerRequestId, RadrootsNostrSignerStoreState, RadrootsNostrSignerWorkflowId,
};
use crate::store::{RadrootsNostrMemorySignerStore, RadrootsNostrSignerStore};
use nostr::{PublicKey, RelayUrl};
@@ -101,6 +102,31 @@ impl RadrootsNostrSignerManager {
.cloned())
}
+ pub fn list_publish_workflows(
+ &self,
+ ) -> Result<Vec<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError> {
+ let guard = self
+ .state
+ .read()
+ .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?;
+ Ok(guard.publish_workflows.clone())
+ }
+
+ pub fn get_publish_workflow(
+ &self,
+ workflow_id: &RadrootsNostrSignerWorkflowId,
+ ) -> Result<Option<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError> {
+ let guard = self
+ .state
+ .read()
+ .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?;
+ Ok(guard
+ .publish_workflows
+ .iter()
+ .find(|record| &record.workflow_id == workflow_id)
+ .cloned())
+ }
+
pub fn find_connections_by_client_public_key(
&self,
client_public_key: &PublicKey,
@@ -522,6 +548,182 @@ impl RadrootsNostrSignerManager {
})
}
+ pub fn begin_connect_secret_publish_finalization(
+ &self,
+ connection_id: &RadrootsNostrSignerConnectionId,
+ ) -> Result<RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerError> {
+ self.update_state_with(|state| {
+ let connection_index = find_connection_index(state, connection_id)?;
+ let record = &state.connections[connection_index];
+ if record.is_terminal() {
+ return Err(RadrootsNostrSignerError::InvalidState(format!(
+ "cannot begin connect secret finalization for {} connection",
+ status_label(record.status)
+ )));
+ }
+ if record.connect_secret_hash.is_none() {
+ return Err(RadrootsNostrSignerError::InvalidState(
+ "connection does not have a connect secret".into(),
+ ));
+ }
+ if record.connect_secret_is_consumed() {
+ return Err(RadrootsNostrSignerError::InvalidState(
+ "connect secret already consumed for connection".into(),
+ ));
+ }
+ ensure_no_active_publish_workflow(
+ state,
+ connection_id,
+ RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization,
+ )?;
+
+ let workflow =
+ RadrootsNostrSignerPublishWorkflowRecord::new_connect_secret_finalization(
+ connection_id.clone(),
+ now_unix_secs(),
+ );
+ state.publish_workflows.push(workflow.clone());
+ Ok(workflow)
+ })
+ }
+
+ pub fn begin_auth_replay_publish_finalization(
+ &self,
+ connection_id: &RadrootsNostrSignerConnectionId,
+ ) -> Result<RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerError> {
+ self.update_state_with(|state| {
+ let authorized_at_unix = now_unix_secs();
+ let connection_index = find_connection_index(state, connection_id)?;
+ let record = &state.connections[connection_index];
+ if record.is_terminal() {
+ return Err(RadrootsNostrSignerError::InvalidState(format!(
+ "cannot begin auth replay finalization for {} connection",
+ status_label(record.status)
+ )));
+ }
+ if record.auth_state != RadrootsNostrSignerAuthState::Pending {
+ return Err(RadrootsNostrSignerError::InvalidState(
+ "auth challenge not pending for connection".into(),
+ ));
+ }
+ if record.auth_challenge.is_none() {
+ return Err(RadrootsNostrSignerError::InvalidState(
+ "auth challenge missing for connection".into(),
+ ));
+ }
+ let pending_request = record.pending_request.clone().ok_or_else(|| {
+ RadrootsNostrSignerError::InvalidState(
+ "pending request missing for auth replay finalization".into(),
+ )
+ })?;
+ ensure_no_active_publish_workflow(
+ state,
+ connection_id,
+ RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization,
+ )?;
+
+ let workflow = RadrootsNostrSignerPublishWorkflowRecord::new_auth_replay_finalization(
+ connection_id.clone(),
+ pending_request,
+ authorized_at_unix,
+ );
+ state.publish_workflows.push(workflow.clone());
+ Ok(workflow)
+ })
+ }
+
+ pub fn mark_publish_workflow_published(
+ &self,
+ workflow_id: &RadrootsNostrSignerWorkflowId,
+ ) -> Result<RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerError> {
+ self.update_state_with(|state| {
+ let workflow = find_publish_workflow_mut(state, workflow_id)?;
+ workflow.mark_published(now_unix_secs());
+ Ok(workflow.clone())
+ })
+ }
+
+ pub fn finalize_publish_workflow(
+ &self,
+ workflow_id: &RadrootsNostrSignerWorkflowId,
+ ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
+ self.update_state_with(|state| {
+ let workflow_index = find_publish_workflow_index(state, workflow_id)?;
+ let workflow = state.publish_workflows[workflow_index].clone();
+ if workflow.state != RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize {
+ return Err(RadrootsNostrSignerError::InvalidState(
+ "publish workflow has not reached published state".into(),
+ ));
+ }
+
+ let record = find_connection_mut(state, &workflow.connection_id)?;
+ let finalized = match workflow.kind {
+ RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization => {
+ if record.connect_secret_hash.is_none() {
+ return Err(RadrootsNostrSignerError::InvalidState(
+ "connection does not have a connect secret".into(),
+ ));
+ }
+ if record.connect_secret_is_consumed() {
+ return Err(RadrootsNostrSignerError::InvalidState(
+ "connect secret already consumed for connection".into(),
+ ));
+ }
+ record.mark_connect_secret_consumed(now_unix_secs());
+ record.clone()
+ }
+ RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization => {
+ if record.auth_state != RadrootsNostrSignerAuthState::Pending {
+ return Err(RadrootsNostrSignerError::InvalidState(
+ "auth challenge not pending for connection".into(),
+ ));
+ }
+ if record.auth_challenge.is_none() {
+ return Err(RadrootsNostrSignerError::InvalidState(
+ "auth challenge missing for connection".into(),
+ ));
+ }
+ let expected_pending_request =
+ workflow.pending_request.clone().ok_or_else(|| {
+ RadrootsNostrSignerError::InvalidState(
+ "auth replay workflow missing pending request".into(),
+ )
+ })?;
+ if record.pending_request.as_ref() != Some(&expected_pending_request) {
+ return Err(RadrootsNostrSignerError::InvalidState(
+ "pending request does not match auth replay workflow".into(),
+ ));
+ }
+ let authorized_at_unix = workflow.authorized_at_unix.ok_or_else(|| {
+ RadrootsNostrSignerError::InvalidState(
+ "auth replay workflow missing authorized timestamp".into(),
+ )
+ })?;
+ let replay = record.authorize_auth_challenge(authorized_at_unix);
+ if replay.as_ref() != Some(&expected_pending_request) {
+ return Err(RadrootsNostrSignerError::InvalidState(
+ "auth replay finalization returned unexpected pending request".into(),
+ ));
+ }
+ record.clone()
+ }
+ };
+
+ state.publish_workflows.remove(workflow_index);
+ Ok(finalized)
+ })
+ }
+
+ pub fn cancel_publish_workflow(
+ &self,
+ workflow_id: &RadrootsNostrSignerWorkflowId,
+ ) -> Result<RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerError> {
+ self.update_state_with(|state| {
+ let workflow_index = find_publish_workflow_index(state, workflow_id)?;
+ Ok(state.publish_workflows.remove(workflow_index))
+ })
+ }
+
pub fn mark_authenticated(
&self,
connection_id: &RadrootsNostrSignerConnectionId,
@@ -690,6 +892,57 @@ fn find_connection_mut<'a>(
.ok_or_else(|| RadrootsNostrSignerError::ConnectionNotFound(connection_id.to_string()))
}
+fn find_connection_index(
+ state: &RadrootsNostrSignerStoreState,
+ connection_id: &RadrootsNostrSignerConnectionId,
+) -> Result<usize, RadrootsNostrSignerError> {
+ state
+ .connections
+ .iter()
+ .position(|record| &record.connection_id == connection_id)
+ .ok_or_else(|| RadrootsNostrSignerError::ConnectionNotFound(connection_id.to_string()))
+}
+
+fn find_publish_workflow_index(
+ state: &RadrootsNostrSignerStoreState,
+ workflow_id: &RadrootsNostrSignerWorkflowId,
+) -> Result<usize, RadrootsNostrSignerError> {
+ state
+ .publish_workflows
+ .iter()
+ .position(|record| &record.workflow_id == workflow_id)
+ .ok_or_else(|| RadrootsNostrSignerError::PublishWorkflowNotFound(workflow_id.to_string()))
+}
+
+fn find_publish_workflow_mut<'a>(
+ state: &'a mut RadrootsNostrSignerStoreState,
+ workflow_id: &RadrootsNostrSignerWorkflowId,
+) -> Result<&'a mut RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerError> {
+ state
+ .publish_workflows
+ .iter_mut()
+ .find(|record| &record.workflow_id == workflow_id)
+ .ok_or_else(|| RadrootsNostrSignerError::PublishWorkflowNotFound(workflow_id.to_string()))
+}
+
+fn ensure_no_active_publish_workflow(
+ state: &RadrootsNostrSignerStoreState,
+ connection_id: &RadrootsNostrSignerConnectionId,
+ kind: RadrootsNostrSignerPublishWorkflowKind,
+) -> Result<(), RadrootsNostrSignerError> {
+ if state
+ .publish_workflows
+ .iter()
+ .any(|record| &record.connection_id == connection_id && record.kind == kind)
+ {
+ return Err(RadrootsNostrSignerError::InvalidState(format!(
+ "publish workflow already active for {}",
+ publish_workflow_kind_label(kind)
+ )));
+ }
+ Ok(())
+}
+
fn validate_public_identity(
identity: &RadrootsIdentityPublic,
) -> Result<(), RadrootsNostrSignerError> {
@@ -803,6 +1056,17 @@ fn status_label(status: RadrootsNostrSignerConnectionStatus) -> &'static str {
}
}
+fn publish_workflow_kind_label(kind: RadrootsNostrSignerPublishWorkflowKind) -> &'static str {
+ match kind {
+ RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization => {
+ "connect_secret_finalization"
+ }
+ RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization => {
+ "auth_replay_finalization"
+ }
+ }
+}
+
fn request_decision(
action: &RadrootsNostrSignerRequestAction,
) -> RadrootsNostrSignerRequestDecision {
@@ -1760,6 +2024,244 @@ mod tests {
}
#[test]
+ fn connect_secret_publish_workflow_is_persisted_and_finalized() {
+ let manager = RadrootsNostrSignerManager::new_in_memory();
+ manager
+ .set_signer_identity(public_identity(0x237))
+ .expect("set signer");
+ let record = manager
+ .register_connection(
+ RadrootsNostrSignerConnectionDraft::new(public_key(0x238), public_identity(0x239))
+ .with_connect_secret("workflow-secret"),
+ )
+ .expect("register");
+
+ let workflow = manager
+ .begin_connect_secret_publish_finalization(&record.connection_id)
+ .expect("begin workflow");
+ assert_eq!(
+ workflow.kind,
+ RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization
+ );
+ assert_eq!(
+ workflow.state,
+ RadrootsNostrSignerPublishWorkflowState::PendingPublish
+ );
+ assert!(workflow.pending_request.is_none());
+ assert!(
+ !manager
+ .get_connection(&record.connection_id)
+ .expect("get")
+ .expect("stored")
+ .connect_secret_is_consumed()
+ );
+ assert_eq!(
+ manager.list_publish_workflows().expect("list workflows"),
+ vec![workflow.clone()]
+ );
+
+ let published = manager
+ .mark_publish_workflow_published(&workflow.workflow_id)
+ .expect("mark published");
+ assert_eq!(
+ published.state,
+ RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize
+ );
+
+ let finalized = manager
+ .finalize_publish_workflow(&workflow.workflow_id)
+ .expect("finalize workflow");
+ assert!(finalized.connect_secret_is_consumed());
+ assert!(
+ manager
+ .list_publish_workflows()
+ .expect("list workflows")
+ .is_empty()
+ );
+ assert!(
+ manager
+ .find_connection_by_connect_secret("workflow-secret")
+ .expect("find secret")
+ .expect("stored")
+ .connect_secret_is_consumed()
+ );
+ }
+
+ #[test]
+ fn auth_replay_publish_workflow_is_persisted_and_finalized() {
+ let manager = RadrootsNostrSignerManager::new_in_memory();
+ manager
+ .set_signer_identity(public_identity(0x23a))
+ .expect("set signer");
+ let record = manager
+ .register_connection(RadrootsNostrSignerConnectionDraft::new(
+ public_key(0x23b),
+ public_identity(0x23c),
+ ))
+ .expect("register");
+
+ manager
+ .require_auth_challenge(
+ &record.connection_id,
+ format!("{}/flow", api_primary_https()).as_str(),
+ )
+ .expect("require auth");
+ let pending = manager
+ .set_pending_request(&record.connection_id, request_message("req-auth-workflow"))
+ .expect("set pending");
+ let pending_request = pending.pending_request.expect("pending request");
+
+ let workflow = manager
+ .begin_auth_replay_publish_finalization(&record.connection_id)
+ .expect("begin auth replay workflow");
+ assert_eq!(
+ workflow.kind,
+ RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization
+ );
+ assert_eq!(workflow.pending_request.as_ref(), Some(&pending_request));
+ assert!(workflow.authorized_at_unix.is_some());
+
+ let stored_before_publish = manager
+ .get_connection(&record.connection_id)
+ .expect("get")
+ .expect("stored");
+ assert_eq!(
+ stored_before_publish.auth_state,
+ RadrootsNostrSignerAuthState::Pending
+ );
+ assert_eq!(
+ stored_before_publish.pending_request.as_ref(),
+ Some(&pending_request)
+ );
+
+ manager
+ .mark_publish_workflow_published(&workflow.workflow_id)
+ .expect("mark published");
+ let finalized = manager
+ .finalize_publish_workflow(&workflow.workflow_id)
+ .expect("finalize auth replay");
+ assert_eq!(
+ finalized.auth_state,
+ RadrootsNostrSignerAuthState::Authorized
+ );
+ assert!(finalized.pending_request.is_none());
+ assert_eq!(
+ finalized
+ .auth_challenge
+ .as_ref()
+ .expect("challenge")
+ .authorized_at_unix,
+ workflow.authorized_at_unix
+ );
+ assert_eq!(
+ finalized.last_authenticated_at_unix,
+ workflow.authorized_at_unix
+ );
+ assert!(
+ manager
+ .list_publish_workflows()
+ .expect("list workflows")
+ .is_empty()
+ );
+ }
+
+ #[test]
+ fn canceling_auth_replay_publish_workflow_preserves_pending_request() {
+ let manager = RadrootsNostrSignerManager::new_in_memory();
+ manager
+ .set_signer_identity(public_identity(0x23d))
+ .expect("set signer");
+ let record = manager
+ .register_connection(RadrootsNostrSignerConnectionDraft::new(
+ public_key(0x23e),
+ public_identity(0x23f),
+ ))
+ .expect("register");
+
+ manager
+ .require_auth_challenge(
+ &record.connection_id,
+ format!("{}/flow", api_primary_https()).as_str(),
+ )
+ .expect("require auth");
+ let pending = manager
+ .set_pending_request(&record.connection_id, request_message("req-auth-cancel"))
+ .expect("set pending");
+ let pending_request = pending.pending_request.expect("pending request");
+
+ let workflow = manager
+ .begin_auth_replay_publish_finalization(&record.connection_id)
+ .expect("begin auth replay workflow");
+ let canceled = manager
+ .cancel_publish_workflow(&workflow.workflow_id)
+ .expect("cancel workflow");
+ assert_eq!(canceled.workflow_id, workflow.workflow_id);
+
+ let stored = manager
+ .get_connection(&record.connection_id)
+ .expect("get")
+ .expect("stored");
+ assert_eq!(stored.auth_state, RadrootsNostrSignerAuthState::Pending);
+ assert_eq!(stored.pending_request.as_ref(), Some(&pending_request));
+ assert!(
+ manager
+ .list_publish_workflows()
+ .expect("list workflows")
+ .is_empty()
+ );
+ }
+
+ #[test]
+ fn publish_workflow_duplicate_and_missing_paths_are_rejected() {
+ let manager = RadrootsNostrSignerManager::new_in_memory();
+ manager
+ .set_signer_identity(public_identity(0x240))
+ .expect("set signer");
+ let record = manager
+ .register_connection(
+ RadrootsNostrSignerConnectionDraft::new(public_key(0x241), public_identity(0x242))
+ .with_connect_secret("duplicate-secret"),
+ )
+ .expect("register");
+
+ let workflow = manager
+ .begin_connect_secret_publish_finalization(&record.connection_id)
+ .expect("begin workflow");
+ let duplicate = manager
+ .begin_connect_secret_publish_finalization(&record.connection_id)
+ .expect_err("duplicate workflow");
+ assert!(
+ duplicate
+ .to_string()
+ .contains("publish workflow already active")
+ );
+
+ let missing_workflow_id = RadrootsNostrSignerWorkflowId::parse("wf-missing").expect("id");
+ let missing_mark = manager
+ .mark_publish_workflow_published(&missing_workflow_id)
+ .expect_err("missing mark");
+ let missing_finalize = manager
+ .finalize_publish_workflow(&missing_workflow_id)
+ .expect_err("missing finalize");
+ let missing_cancel = manager
+ .cancel_publish_workflow(&missing_workflow_id)
+ .expect_err("missing cancel");
+
+ for err in [missing_mark, missing_finalize, missing_cancel] {
+ assert!(err.to_string().contains("publish workflow not found"));
+ }
+
+ let unpublished_finalize = manager
+ .finalize_publish_workflow(&workflow.workflow_id)
+ .expect_err("unpublished finalize");
+ assert!(
+ unpublished_finalize
+ .to_string()
+ .contains("publish workflow has not reached published state")
+ );
+ }
+
+ #[test]
fn manager_reports_missing_connections_and_save_failures() {
let manager = RadrootsNostrSignerManager::new_in_memory();
let missing_id = RadrootsNostrSignerConnectionId::parse("missing").expect("id");
@@ -1780,6 +2282,29 @@ mod tests {
.set_signer_identity(public_identity(0x33))
.expect_err("save error");
assert!(err.to_string().contains("store save failed"));
+
+ let signer_identity = public_identity(0x243);
+ let connection = RadrootsNostrSignerConnectionRecord::new(
+ RadrootsNostrSignerConnectionId::parse("conn-save-error").expect("id"),
+ signer_identity.clone(),
+ RadrootsNostrSignerConnectionDraft::new(public_key(0x244), public_identity(0x245))
+ .with_connect_secret("save-error-secret"),
+ 1,
+ );
+ let manager = RadrootsNostrSignerManager::new(Arc::new(SaveErrorStore::new(
+ RadrootsNostrSignerStoreState {
+ version: RADROOTS_NOSTR_SIGNER_STORE_VERSION,
+ signer_identity: Some(signer_identity),
+ connections: vec![connection.clone()],
+ audit_records: Vec::new(),
+ publish_workflows: Vec::new(),
+ },
+ )))
+ .expect("manager with preloaded state");
+ let workflow_err = manager
+ .begin_connect_secret_publish_finalization(&connection.connection_id)
+ .expect_err("workflow save error");
+ assert!(workflow_err.to_string().contains("store save failed"));
}
#[test]
@@ -1937,6 +2462,12 @@ mod tests {
let audit_for_connection_err = manager
.audit_records_for_connection(&connection_id)
.expect_err("poisoned audit connection");
+ let workflow_list_err = manager
+ .list_publish_workflows()
+ .expect_err("poisoned workflow list");
+ let workflow_get_err = manager
+ .get_publish_workflow(&RadrootsNostrSignerWorkflowId::parse("wf-poison").expect("id"))
+ .expect_err("poisoned workflow get");
let find_secret_err = manager
.find_connection_by_connect_secret("secret")
.expect_err("poisoned secret lookup");
@@ -1955,6 +2486,8 @@ mod tests {
list_err,
audit_list_err,
audit_for_connection_err,
+ workflow_list_err,
+ workflow_get_err,
find_secret_err,
find_client_err,
lookup_secret_err,
@@ -1998,6 +2531,7 @@ mod tests {
let signer_identity = public_identity(0x48);
let connection_id = RadrootsNostrSignerConnectionId::parse("conn-2").expect("id");
+ let workflow_id = RadrootsNostrSignerWorkflowId::parse("wf-2").expect("id");
let connect_draft =
RadrootsNostrSignerConnectionDraft::new(public_key(0x49), public_identity(0x50));
@@ -2034,6 +2568,21 @@ mod tests {
let authorize_auth_err = manager
.authorize_auth_challenge(&connection_id)
.expect_err("poisoned authorize auth");
+ let begin_connect_workflow_err = manager
+ .begin_connect_secret_publish_finalization(&connection_id)
+ .expect_err("poisoned connect workflow");
+ let begin_auth_workflow_err = manager
+ .begin_auth_replay_publish_finalization(&connection_id)
+ .expect_err("poisoned auth workflow");
+ let mark_workflow_err = manager
+ .mark_publish_workflow_published(&workflow_id)
+ .expect_err("poisoned mark workflow");
+ let finalize_workflow_err = manager
+ .finalize_publish_workflow(&workflow_id)
+ .expect_err("poisoned finalize workflow");
+ let cancel_workflow_err = manager
+ .cancel_publish_workflow(&workflow_id)
+ .expect_err("poisoned cancel workflow");
let auth_err = manager
.mark_authenticated(&connection_id)
.expect_err("poisoned auth");
@@ -2058,6 +2607,11 @@ mod tests {
require_auth_err,
set_pending_request_err,
authorize_auth_err,
+ begin_connect_workflow_err,
+ begin_auth_workflow_err,
+ mark_workflow_err,
+ finalize_workflow_err,
+ cancel_workflow_err,
auth_err,
request_err,
] {
diff --git a/crates/nostr-signer/src/migrations.rs b/crates/nostr-signer/src/migrations.rs
@@ -6,11 +6,18 @@ use radroots_sql_core::error::SqlError;
use radroots_sql_core::migrations::{Migration, migrations_run_all_down, migrations_run_all_up};
#[cfg(feature = "native")]
-pub static MIGRATIONS: &[Migration] = &[Migration {
- name: "0000_init",
- up_sql: include_str!("../migrations/0000_init.up.sql"),
- down_sql: include_str!("../migrations/0000_init.down.sql"),
-}];
+pub static MIGRATIONS: &[Migration] = &[
+ Migration {
+ name: "0000_init",
+ up_sql: include_str!("../migrations/0000_init.up.sql"),
+ down_sql: include_str!("../migrations/0000_init.down.sql"),
+ },
+ Migration {
+ name: "0001_publish_workflows",
+ up_sql: include_str!("../migrations/0001_publish_workflows.up.sql"),
+ down_sql: include_str!("../migrations/0001_publish_workflows.down.sql"),
+ },
+];
#[cfg(feature = "native")]
pub fn run_all_up<E>(executor: &E) -> Result<(), SqlError>
diff --git a/crates/nostr-signer/src/model.rs b/crates/nostr-signer/src/model.rs
@@ -21,6 +21,9 @@ pub struct RadrootsNostrSignerConnectionId(String);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RadrootsNostrSignerRequestId(String);
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct RadrootsNostrSignerWorkflowId(String);
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RadrootsNostrSignerApprovalRequirement {
NotRequired,
@@ -44,6 +47,20 @@ pub enum RadrootsNostrSignerConnectionStatus {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum RadrootsNostrSignerPublishWorkflowKind {
+ ConnectSecretFinalization,
+ AuthReplayFinalization,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum RadrootsNostrSignerPublishWorkflowState {
+ PendingPublish,
+ PublishedPendingFinalize,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RadrootsNostrSignerRequestDecision {
Allowed,
Denied,
@@ -159,12 +176,28 @@ pub struct RadrootsNostrSignerRequestAuditRecord {
pub created_at_unix: u64,
}
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct RadrootsNostrSignerPublishWorkflowRecord {
+ pub workflow_id: RadrootsNostrSignerWorkflowId,
+ pub connection_id: RadrootsNostrSignerConnectionId,
+ pub kind: RadrootsNostrSignerPublishWorkflowKind,
+ pub state: RadrootsNostrSignerPublishWorkflowState,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub pending_request: Option<RadrootsNostrSignerPendingRequest>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub authorized_at_unix: Option<u64>,
+ pub created_at_unix: u64,
+ pub updated_at_unix: u64,
+}
+
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RadrootsNostrSignerStoreState {
pub version: u32,
pub signer_identity: Option<RadrootsIdentityPublic>,
pub connections: Vec<RadrootsNostrSignerConnectionRecord>,
pub audit_records: Vec<RadrootsNostrSignerRequestAuditRecord>,
+ #[serde(default)]
+ pub publish_workflows: Vec<RadrootsNostrSignerPublishWorkflowRecord>,
}
#[derive(Debug, Clone, Deserialize)]
@@ -240,6 +273,50 @@ impl RadrootsNostrSignerRequestId {
}
}
+impl RadrootsNostrSignerWorkflowId {
+ pub fn new_v7() -> Self {
+ Self(Uuid::now_v7().to_string())
+ }
+
+ pub fn parse(value: &str) -> Result<Self, RadrootsNostrSignerError> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ return Err(RadrootsNostrSignerError::InvalidWorkflowId(
+ value.to_owned(),
+ ));
+ }
+ Ok(Self(trimmed.to_owned()))
+ }
+
+ pub fn as_str(&self) -> &str {
+ self.0.as_str()
+ }
+
+ pub fn into_string(self) -> String {
+ self.0
+ }
+}
+
+impl fmt::Display for RadrootsNostrSignerWorkflowId {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self.as_str())
+ }
+}
+
+impl AsRef<str> for RadrootsNostrSignerWorkflowId {
+ fn as_ref(&self) -> &str {
+ self.as_str()
+ }
+}
+
+impl FromStr for RadrootsNostrSignerWorkflowId {
+ type Err = RadrootsNostrSignerError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ Self::parse(value)
+ }
+}
+
impl fmt::Display for RadrootsNostrSignerRequestId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
@@ -579,6 +656,46 @@ impl RadrootsNostrSignerRequestAuditRecord {
}
}
+impl RadrootsNostrSignerPublishWorkflowRecord {
+ pub fn new_connect_secret_finalization(
+ connection_id: RadrootsNostrSignerConnectionId,
+ created_at_unix: u64,
+ ) -> Self {
+ Self {
+ workflow_id: RadrootsNostrSignerWorkflowId::new_v7(),
+ connection_id,
+ kind: RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization,
+ state: RadrootsNostrSignerPublishWorkflowState::PendingPublish,
+ pending_request: None,
+ authorized_at_unix: None,
+ created_at_unix,
+ updated_at_unix: created_at_unix,
+ }
+ }
+
+ pub fn new_auth_replay_finalization(
+ connection_id: RadrootsNostrSignerConnectionId,
+ pending_request: RadrootsNostrSignerPendingRequest,
+ authorized_at_unix: u64,
+ ) -> Self {
+ Self {
+ workflow_id: RadrootsNostrSignerWorkflowId::new_v7(),
+ connection_id,
+ kind: RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization,
+ state: RadrootsNostrSignerPublishWorkflowState::PendingPublish,
+ pending_request: Some(pending_request),
+ authorized_at_unix: Some(authorized_at_unix),
+ created_at_unix: authorized_at_unix,
+ updated_at_unix: authorized_at_unix,
+ }
+ }
+
+ pub fn mark_published(&mut self, updated_at_unix: u64) {
+ self.state = RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize;
+ self.updated_at_unix = updated_at_unix;
+ }
+}
+
impl Default for RadrootsNostrSignerStoreState {
fn default() -> Self {
Self {
@@ -586,6 +703,7 @@ impl Default for RadrootsNostrSignerStoreState {
signer_identity: None,
connections: Vec::new(),
audit_records: Vec::new(),
+ publish_workflows: Vec::new(),
}
}
}
@@ -669,31 +787,41 @@ mod tests {
fn connection_and_request_ids_parse_and_display() {
let connection_id = RadrootsNostrSignerConnectionId::parse("conn-1").expect("connection");
let request_id = RadrootsNostrSignerRequestId::parse("req-1").expect("request");
+ let workflow_id = RadrootsNostrSignerWorkflowId::parse("wf-1").expect("workflow");
assert_eq!(connection_id.as_str(), "conn-1");
assert_eq!(request_id.as_str(), "req-1");
+ assert_eq!(workflow_id.as_str(), "wf-1");
assert_eq!(connection_id.as_ref(), "conn-1");
assert_eq!(request_id.as_ref(), "req-1");
+ assert_eq!(workflow_id.as_ref(), "wf-1");
assert_eq!(connection_id.to_string(), "conn-1");
assert_eq!(request_id.to_string(), "req-1");
+ assert_eq!(workflow_id.to_string(), "wf-1");
assert_eq!(connection_id.clone().into_string(), "conn-1");
assert_eq!(request_id.clone().into_string(), "req-1");
+ assert_eq!(workflow_id.clone().into_string(), "wf-1");
let parsed_connection =
RadrootsNostrSignerConnectionId::from_str("conn-1").expect("from_str connection");
let parsed_request =
RadrootsNostrSignerRequestId::from_str("req-1").expect("from_str request");
+ let parsed_workflow =
+ RadrootsNostrSignerWorkflowId::from_str("wf-1").expect("from_str workflow");
assert_eq!(parsed_connection, connection_id);
assert_eq!(parsed_request, request_id);
+ assert_eq!(parsed_workflow, workflow_id);
}
#[test]
fn generated_ids_are_non_empty() {
let connection_id = RadrootsNostrSignerConnectionId::new_v7();
let request_id = RadrootsNostrSignerRequestId::new_v7();
+ let workflow_id = RadrootsNostrSignerWorkflowId::new_v7();
assert!(!connection_id.as_ref().is_empty());
assert!(!request_id.as_ref().is_empty());
+ assert!(!workflow_id.as_ref().is_empty());
}
#[test]
@@ -701,9 +829,11 @@ mod tests {
let connection_err =
RadrootsNostrSignerConnectionId::parse(" ").expect_err("empty connection");
let request_err = RadrootsNostrSignerRequestId::parse("").expect_err("empty request");
+ let workflow_err = RadrootsNostrSignerWorkflowId::parse(" ").expect_err("empty workflow");
assert!(connection_err.to_string().contains("invalid connection id"));
assert!(request_err.to_string().contains("invalid request id"));
+ assert!(workflow_err.to_string().contains("invalid workflow id"));
}
#[test]
@@ -859,6 +989,54 @@ mod tests {
}
#[test]
+ fn publish_workflow_records_cover_connect_secret_and_auth_replay_lifecycle() {
+ let connection_id = RadrootsNostrSignerConnectionId::parse("conn-workflow").expect("id");
+ let pending_request =
+ RadrootsNostrSignerPendingRequest::new(request_message("req-workflow"), 41)
+ .expect("pending request");
+
+ let connect_secret =
+ RadrootsNostrSignerPublishWorkflowRecord::new_connect_secret_finalization(
+ connection_id.clone(),
+ 40,
+ );
+ assert_eq!(
+ connect_secret.kind,
+ RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization
+ );
+ assert_eq!(
+ connect_secret.state,
+ RadrootsNostrSignerPublishWorkflowState::PendingPublish
+ );
+ assert!(connect_secret.pending_request.is_none());
+ assert!(connect_secret.authorized_at_unix.is_none());
+
+ let mut auth_replay =
+ RadrootsNostrSignerPublishWorkflowRecord::new_auth_replay_finalization(
+ connection_id,
+ pending_request.clone(),
+ 42,
+ );
+ assert_eq!(
+ auth_replay.kind,
+ RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization
+ );
+ assert_eq!(
+ auth_replay.state,
+ RadrootsNostrSignerPublishWorkflowState::PendingPublish
+ );
+ assert_eq!(auth_replay.pending_request, Some(pending_request));
+ assert_eq!(auth_replay.authorized_at_unix, Some(42));
+
+ auth_replay.mark_published(43);
+ assert_eq!(
+ auth_replay.state,
+ RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize
+ );
+ assert_eq!(auth_replay.updated_at_unix, 43);
+ }
+
+ #[test]
fn effective_permissions_prefers_grants_then_auto_requested_then_empty() {
let requested: RadrootsNostrConnectPermissions = vec![RadrootsNostrConnectPermission::new(
RadrootsNostrConnectMethod::Nip04Encrypt,
diff --git a/crates/nostr-signer/src/sqlite.rs b/crates/nostr-signer/src/sqlite.rs
@@ -158,13 +158,18 @@ mod tests {
.iter()
.any(|name| name == "signer_request_audit")
);
+ assert!(
+ table_names
+ .iter()
+ .any(|name| name == "signer_publish_workflow")
+ );
let migration_count = query_single_i64(
&db,
"SELECT COUNT(*) AS applied_count FROM __migrations",
"applied_count",
);
- assert_eq!(migration_count, 1);
+ assert_eq!(migration_count, 2);
let store_version = query_single_i64(
&db,
@@ -215,6 +220,6 @@ mod tests {
"SELECT COUNT(*) AS applied_count FROM __migrations",
"applied_count",
);
- assert_eq!(migration_count, 1);
+ assert_eq!(migration_count, 2);
}
}
diff --git a/crates/nostr-signer/src/store.rs b/crates/nostr-signer/src/store.rs
@@ -14,8 +14,9 @@ use crate::model::{
RadrootsNostrSignerAuthChallenge, RadrootsNostrSignerAuthState,
RadrootsNostrSignerConnectSecretHash, RadrootsNostrSignerConnectionRecord,
RadrootsNostrSignerConnectionStatus, RadrootsNostrSignerPendingRequest,
- RadrootsNostrSignerPermissionGrant, RadrootsNostrSignerRequestAuditRecord,
- RadrootsNostrSignerRequestDecision,
+ RadrootsNostrSignerPermissionGrant, RadrootsNostrSignerPublishWorkflowKind,
+ RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerPublishWorkflowState,
+ RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestDecision,
};
#[cfg(feature = "native")]
use crate::sqlite::RadrootsNostrSignerSqliteDb;
@@ -161,6 +162,7 @@ impl RadrootsNostrSignerStore for RadrootsNostrSqliteSignerStore {
.transpose()?,
connections: Vec::new(),
audit_records: Vec::new(),
+ publish_workflows: Vec::new(),
};
let connection_rows: Vec<SignerConnectionRow> = query_rows(
@@ -266,6 +268,15 @@ impl RadrootsNostrSignerStore for RadrootsNostrSqliteSignerStore {
.map(SignerRequestAuditRow::into_record)
.collect::<Result<Vec<_>, _>>()?;
+ let workflow_rows: Vec<SignerPublishWorkflowRow> = query_rows(
+ self.db.as_ref(),
+ "SELECT workflow_id, connection_id, kind, state, pending_request_json, authorized_at_unix, created_at_unix, updated_at_unix FROM signer_publish_workflow ORDER BY created_at_unix, workflow_id",
+ )?;
+ state.publish_workflows = workflow_rows
+ .into_iter()
+ .map(SignerPublishWorkflowRow::into_record)
+ .collect::<Result<Vec<_>, _>>()?;
+
Ok(state)
}
@@ -273,6 +284,7 @@ impl RadrootsNostrSignerStore for RadrootsNostrSqliteSignerStore {
let executor = self.db.executor();
executor.begin()?;
let result = (|| -> Result<(), RadrootsNostrSignerError> {
+ exec_json(executor, "DELETE FROM signer_publish_workflow", json!([]))?;
exec_json(executor, "DELETE FROM signer_request_audit", json!([]))?;
exec_json(executor, "DELETE FROM signer_connection", json!([]))?;
@@ -402,6 +414,27 @@ impl RadrootsNostrSignerStore for RadrootsNostrSqliteSignerStore {
)?;
}
+ for workflow in &state.publish_workflows {
+ exec_json(
+ executor,
+ "INSERT INTO signer_publish_workflow(workflow_id, connection_id, kind, state, pending_request_json, authorized_at_unix, created_at_unix, updated_at_unix) VALUES(?, ?, ?, ?, ?, ?, ?, ?)",
+ json!([
+ workflow.workflow_id.as_str(),
+ workflow.connection_id.as_str(),
+ publish_workflow_kind_label(workflow.kind),
+ publish_workflow_state_label(workflow.state),
+ workflow
+ .pending_request
+ .as_ref()
+ .map(serde_json::to_string)
+ .transpose()?,
+ workflow.authorized_at_unix,
+ workflow.created_at_unix,
+ workflow.updated_at_unix,
+ ]),
+ )?;
+ }
+
Ok(())
})();
@@ -567,6 +600,41 @@ impl SignerRequestAuditRow {
}
#[cfg(feature = "native")]
+#[derive(Debug, Deserialize)]
+struct SignerPublishWorkflowRow {
+ workflow_id: String,
+ connection_id: String,
+ kind: String,
+ state: String,
+ pending_request_json: Option<String>,
+ authorized_at_unix: Option<u64>,
+ created_at_unix: u64,
+ updated_at_unix: u64,
+}
+
+#[cfg(feature = "native")]
+impl SignerPublishWorkflowRow {
+ fn into_record(
+ self,
+ ) -> Result<RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerError> {
+ Ok(RadrootsNostrSignerPublishWorkflowRecord {
+ workflow_id: self.workflow_id.parse()?,
+ connection_id: self.connection_id.parse()?,
+ kind: parse_publish_workflow_kind(self.kind.as_str())?,
+ state: parse_publish_workflow_state(self.state.as_str())?,
+ pending_request: self
+ .pending_request_json
+ .as_deref()
+ .map(parse_json_field::<RadrootsNostrSignerPendingRequest>)
+ .transpose()?,
+ authorized_at_unix: self.authorized_at_unix,
+ created_at_unix: self.created_at_unix,
+ updated_at_unix: self.updated_at_unix,
+ })
+ }
+}
+
+#[cfg(feature = "native")]
fn query_rows<T: DeserializeOwned>(
db: &RadrootsNostrSignerSqliteDb,
sql: &str,
@@ -713,6 +781,60 @@ fn parse_request_decision(
}
#[cfg(feature = "native")]
+fn publish_workflow_kind_label(value: RadrootsNostrSignerPublishWorkflowKind) -> &'static str {
+ match value {
+ RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization => {
+ "connect_secret_finalization"
+ }
+ RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization => {
+ "auth_replay_finalization"
+ }
+ }
+}
+
+#[cfg(feature = "native")]
+fn parse_publish_workflow_kind(
+ value: &str,
+) -> Result<RadrootsNostrSignerPublishWorkflowKind, RadrootsNostrSignerError> {
+ match value {
+ "connect_secret_finalization" => {
+ Ok(RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization)
+ }
+ "auth_replay_finalization" => {
+ Ok(RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization)
+ }
+ other => Err(RadrootsNostrSignerError::Store(format!(
+ "unknown sqlite publish workflow kind `{other}`"
+ ))),
+ }
+}
+
+#[cfg(feature = "native")]
+fn publish_workflow_state_label(value: RadrootsNostrSignerPublishWorkflowState) -> &'static str {
+ match value {
+ RadrootsNostrSignerPublishWorkflowState::PendingPublish => "pending_publish",
+ RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize => {
+ "published_pending_finalize"
+ }
+ }
+}
+
+#[cfg(feature = "native")]
+fn parse_publish_workflow_state(
+ value: &str,
+) -> Result<RadrootsNostrSignerPublishWorkflowState, RadrootsNostrSignerError> {
+ match value {
+ "pending_publish" => Ok(RadrootsNostrSignerPublishWorkflowState::PendingPublish),
+ "published_pending_finalize" => {
+ Ok(RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize)
+ }
+ other => Err(RadrootsNostrSignerError::Store(format!(
+ "unknown sqlite publish workflow state `{other}`"
+ ))),
+ }
+}
+
+#[cfg(feature = "native")]
fn secret_digest_algorithm_label(hash: &RadrootsNostrSignerConnectSecretHash) -> &'static str {
match hash.algorithm {
crate::model::RadrootsNostrSignerSecretDigestAlgorithm::Sha256 => "sha256",
@@ -739,8 +861,9 @@ mod tests {
RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerAuthChallenge,
RadrootsNostrSignerAuthState, RadrootsNostrSignerConnectionDraft,
RadrootsNostrSignerConnectionId, RadrootsNostrSignerPendingRequest,
- RadrootsNostrSignerPermissionGrant, RadrootsNostrSignerRequestAuditRecord,
- RadrootsNostrSignerRequestDecision, RadrootsNostrSignerRequestId,
+ RadrootsNostrSignerPermissionGrant, RadrootsNostrSignerPublishWorkflowRecord,
+ RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestDecision,
+ RadrootsNostrSignerRequestId,
};
#[cfg(feature = "native")]
use crate::test_support::{
@@ -923,6 +1046,21 @@ mod tests {
Some("permitted".to_owned()),
150,
)],
+ publish_workflows: vec![
+ RadrootsNostrSignerPublishWorkflowRecord::new_connect_secret_finalization(
+ connection.connection_id.clone(),
+ 151,
+ ),
+ RadrootsNostrSignerPublishWorkflowRecord::new_auth_replay_finalization(
+ connection.connection_id.clone(),
+ RadrootsNostrSignerPendingRequest::new(
+ sample_request_message("req-replay"),
+ 152,
+ )
+ .expect("auth replay pending request"),
+ 153,
+ ),
+ ],
}
}