lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

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:
Acrates/nostr-signer/migrations/0001_publish_workflows.down.sql | 1+
Acrates/nostr-signer/migrations/0001_publish_workflows.up.sql | 16++++++++++++++++
Mcrates/nostr-signer/src/error.rs | 6++++++
Mcrates/nostr-signer/src/lib.rs | 8+++++---
Mcrates/nostr-signer/src/manager.rs | 560++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/nostr-signer/src/migrations.rs | 17++++++++++++-----
Mcrates/nostr-signer/src/model.rs | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr-signer/src/sqlite.rs | 9+++++++--
Mcrates/nostr-signer/src/store.rs | 146++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
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, + ), + ], } }