myc

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

commit 069ccb703a03a042ea13170e8076ca06c8cfa85d
parent 961787013babf953e8dbdf2dc059a94081b0fc8d
Author: triesap <tyson@radroots.org>
Date:   Fri, 21 Aug 2026 19:40:01 +0000

state: remove prototype persistence

Diffstat:
MREADME | 17+++++++++++++----
Dmigrations/0000_delivery_outbox_init.down.sql | 6------
Dmigrations/0000_delivery_outbox_init.up.sql | 32--------------------------------
Dmigrations/0000_runtime_audit_init.down.sql | 5-----
Dmigrations/0000_runtime_audit_init.up.sql | 31-------------------------------
Dmigrations/signer/0000_init.down.sql | 8--------
Dmigrations/signer/0000_init.up.sql | 97-------------------------------------------------------------------------------
Dmigrations/signer/0001_publish_workflows.down.sql | 1-
Dmigrations/signer/0001_publish_workflows.up.sql | 16----------------
Dmigrations/signer/0002_client_metadata.down.sql | 2--
Dmigrations/signer/0002_client_metadata.up.sql | 2--
Dsrc/app/backend.rs | 416-------------------------------------------------------------------------------
Dsrc/app/mod.rs | 134-------------------------------------------------------------------------------
Dsrc/app/runtime.rs | 2026-------------------------------------------------------------------------------
Dsrc/audit.rs | 1074-------------------------------------------------------------------------------
Dsrc/audit_sqlite.rs | 786-------------------------------------------------------------------------------
Dsrc/config.rs | 1492-------------------------------------------------------------------------------
Dsrc/control.rs | 888-------------------------------------------------------------------------------
Dsrc/discovery.rs | 2321-------------------------------------------------------------------------------
Msrc/lib.rs | 89+++----------------------------------------------------------------------------
Dsrc/operability/mod.rs | 1924-------------------------------------------------------------------------------
Dsrc/operability/server.rs | 103-------------------------------------------------------------------------------
Dsrc/outbox.rs | 340-------------------------------------------------------------------------------
Dsrc/outbox_sqlite.rs | 601-------------------------------------------------------------------------------
Dsrc/paths.rs | 176-------------------------------------------------------------------------------
Dsrc/persistence.rs | 2014-------------------------------------------------------------------------------
Dsrc/signer/migrations.rs | 35-----------------------------------
Dsrc/signer/sqlite.rs | 291------------------------------------------------------------------------------
Dsrc/signer/store.rs | 1097-------------------------------------------------------------------------------
Dsrc/sql.rs | 308-------------------------------------------------------------------------------
Msrc/state_discovery.rs | 7++++---
Msrc/state_metadata.rs | 7++++---
Dsrc/transport.rs | 715-------------------------------------------------------------------------------
Dsrc/transport/nip46.rs | 2060-------------------------------------------------------------------------------
Dtests/nip46_e2e.rs | 5046-------------------------------------------------------------------------------
Dtests/operability_e2e.rs | 425-------------------------------------------------------------------------------
Dtests/operability_server.rs | 280-------------------------------------------------------------------------------
Mtests/services_hardening_discovery_state.rs | 21+++++++++++----------
Mtests/services_hardening_legacy_removal.rs | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mtests/services_hardening_runtime_context.rs | 44++++++++------------------------------------
Mtests/services_hardening_signer_request_state.rs | 10++++++++--
Mtests/services_hardening_state_repository.rs | 7++++++-
Dtests/support/mod.rs | 25-------------------------
43 files changed, 147 insertions(+), 24935 deletions(-)

diff --git a/README b/README @@ -47,10 +47,10 @@ without changing the canonical common artifact inventory. Create-new initialization reserves the shared schema-v1 metadata and migration ledger, retains exclusive writer authority, applies the exact Myc schema-v2 -through schema-v6 migrations, binds the normalized configuration, expected +through schema-v7 migrations, binds the normalized configuration, expected identity roles, and policy versions through a sealed typed repository, and explicitly closes the host before reporting success. Existing writable open can -resume any exact v1 through v5 prefix; read-only inspection requires the current +resume any exact v1 through v6 prefix; read-only inspection requires the current catalog and exact immutable Myc binding. The public Myc repository exposes no raw pool, connection, transaction-control @@ -62,8 +62,17 @@ authorization-challenge tables and guards are checksum-pinned service-owned schema objects. Schema v5 adds checksum-pinned audit-sequence, safe operation audit, request-audit binding, and bounded rate-window objects. Schema v6 adds checksum-pinned publication-job, target, attempt, transition-guard, and -no-delete objects; discovery and exact signed-event storage remain owned by -their later ordered repository steps. +no-delete objects. Schema v7 adds desired/current discovery state, exact signed +NIP-89 event bytes, deterministic NIP-05 projection inputs, and their delivery +binding. + +The earlier JSON signer store, JSONL audit log, separate signer/audit/outbox +SQLite databases, prototype import/backup adapter, independent migration +directories, and their runtime/operability consumers have been removed. The +only authoritative service-state database is the canonical `state.sqlite` and +the only writer authority is its retained `state.lock`; there is no backend +selection, compatibility reader, prototype importer, or secondary migration +engine. Signer-request admission validates bounded client, request, event, method, canonical request, injected operation entropy, and injected time evidence diff --git a/migrations/0000_delivery_outbox_init.down.sql b/migrations/0000_delivery_outbox_init.down.sql @@ -1,6 +0,0 @@ -DROP INDEX IF EXISTS idx_myc_delivery_outbox_signer_workflow_id; -DROP INDEX IF EXISTS idx_myc_delivery_outbox_attempt_id; -DROP INDEX IF EXISTS idx_myc_delivery_outbox_request_id; -DROP INDEX IF EXISTS idx_myc_delivery_outbox_connection_id; -DROP INDEX IF EXISTS idx_myc_delivery_outbox_status; -DROP TABLE IF EXISTS myc_delivery_outbox; diff --git a/migrations/0000_delivery_outbox_init.up.sql b/migrations/0000_delivery_outbox_init.up.sql @@ -1,32 +0,0 @@ -CREATE TABLE myc_delivery_outbox ( - job_id TEXT PRIMARY KEY, - kind TEXT NOT NULL, - status TEXT NOT NULL, - event_json TEXT NOT NULL, - relay_urls_json TEXT NOT NULL, - connection_id TEXT, - request_id TEXT, - attempt_id TEXT, - signer_publish_workflow_id TEXT, - publish_attempt_count INTEGER NOT NULL, - last_error TEXT, - created_at_unix INTEGER NOT NULL, - updated_at_unix INTEGER NOT NULL, - published_at_unix INTEGER, - finalized_at_unix INTEGER -); - -CREATE INDEX idx_myc_delivery_outbox_status - ON myc_delivery_outbox(status, created_at_unix, job_id); - -CREATE INDEX idx_myc_delivery_outbox_connection_id - ON myc_delivery_outbox(connection_id, created_at_unix, job_id); - -CREATE INDEX idx_myc_delivery_outbox_request_id - ON myc_delivery_outbox(request_id, created_at_unix, job_id); - -CREATE INDEX idx_myc_delivery_outbox_attempt_id - ON myc_delivery_outbox(attempt_id, created_at_unix, job_id); - -CREATE INDEX idx_myc_delivery_outbox_signer_workflow_id - ON myc_delivery_outbox(signer_publish_workflow_id, created_at_unix, job_id); diff --git a/migrations/0000_runtime_audit_init.down.sql b/migrations/0000_runtime_audit_init.down.sql @@ -1,5 +0,0 @@ -DROP INDEX IF EXISTS idx_myc_operation_audit_operation_attempt; -DROP INDEX IF EXISTS idx_myc_operation_audit_attempt_id; -DROP INDEX IF EXISTS idx_myc_operation_audit_connection_id; -DROP INDEX IF EXISTS idx_myc_operation_audit_recorded_at; -DROP TABLE IF EXISTS myc_operation_audit; diff --git a/migrations/0000_runtime_audit_init.up.sql b/migrations/0000_runtime_audit_init.up.sql @@ -1,31 +0,0 @@ -CREATE TABLE myc_operation_audit ( - audit_record_id INTEGER PRIMARY KEY, - recorded_at_unix INTEGER NOT NULL, - operation TEXT NOT NULL, - outcome TEXT NOT NULL, - relay_url TEXT, - connection_id TEXT, - request_id TEXT, - attempt_id TEXT, - planned_repair_relays_json TEXT NOT NULL, - blocked_relays_json TEXT NOT NULL, - blocked_reason TEXT, - delivery_policy TEXT, - required_acknowledged_relay_count INTEGER, - publish_attempt_count INTEGER, - relay_count INTEGER NOT NULL, - acknowledged_relay_count INTEGER NOT NULL, - relay_outcome_summary TEXT NOT NULL -); - -CREATE INDEX idx_myc_operation_audit_recorded_at - ON myc_operation_audit(recorded_at_unix, audit_record_id); - -CREATE INDEX idx_myc_operation_audit_connection_id - ON myc_operation_audit(connection_id, recorded_at_unix, audit_record_id); - -CREATE INDEX idx_myc_operation_audit_attempt_id - ON myc_operation_audit(attempt_id, recorded_at_unix, audit_record_id); - -CREATE INDEX idx_myc_operation_audit_operation_attempt - ON myc_operation_audit(operation, recorded_at_unix, audit_record_id); diff --git a/migrations/signer/0000_init.down.sql b/migrations/signer/0000_init.down.sql @@ -1,8 +0,0 @@ -DROP TABLE IF EXISTS signer_request_audit; -DROP TABLE IF EXISTS signer_connection_pending_request; -DROP TABLE IF EXISTS signer_connection_auth_challenge; -DROP TABLE IF EXISTS signer_connection_relay; -DROP TABLE IF EXISTS signer_connection_permission_grant; -DROP TABLE IF EXISTS signer_connection; -DELETE FROM signer_store_metadata WHERE singleton_id = 1; -DROP TABLE IF EXISTS signer_store_metadata; diff --git a/migrations/signer/0000_init.up.sql b/migrations/signer/0000_init.up.sql @@ -1,97 +0,0 @@ -CREATE TABLE IF NOT EXISTS signer_store_metadata ( - singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1), - store_version INTEGER NOT NULL, - signer_identity_id TEXT, - signer_identity_public_key_hex TEXT, - signer_identity_json TEXT, - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -INSERT OR IGNORE INTO signer_store_metadata (singleton_id, store_version) -VALUES (1, 1); - -CREATE TABLE IF NOT EXISTS signer_connection ( - connection_id TEXT PRIMARY KEY, - client_public_key_hex TEXT NOT NULL, - signer_identity_id TEXT NOT NULL, - signer_identity_public_key_hex TEXT NOT NULL, - signer_identity_json TEXT NOT NULL, - user_identity_id TEXT NOT NULL, - user_identity_public_key_hex TEXT NOT NULL, - user_identity_json TEXT NOT NULL, - connect_secret_hash_algorithm TEXT, - connect_secret_hash_digest_hex TEXT, - connect_secret_consumed_at_unix INTEGER, - requested_permissions_json TEXT NOT NULL, - approval_requirement TEXT NOT NULL, - approval_state TEXT NOT NULL, - auth_state TEXT NOT NULL, - status TEXT NOT NULL, - status_reason TEXT, - created_at_unix INTEGER NOT NULL, - updated_at_unix INTEGER NOT NULL, - last_authenticated_at_unix INTEGER, - last_request_at_unix INTEGER -); - -CREATE INDEX IF NOT EXISTS signer_connection_client_public_key_idx -ON signer_connection (client_public_key_hex); - -CREATE INDEX IF NOT EXISTS signer_connection_user_identity_idx -ON signer_connection (user_identity_id); - -CREATE INDEX IF NOT EXISTS signer_connection_connect_secret_digest_idx -ON signer_connection (connect_secret_hash_digest_hex) -WHERE connect_secret_hash_digest_hex IS NOT NULL; - -CREATE INDEX IF NOT EXISTS signer_connection_status_idx -ON signer_connection (status); - -CREATE TABLE IF NOT EXISTS signer_connection_permission_grant ( - connection_id TEXT NOT NULL REFERENCES signer_connection (connection_id) ON DELETE CASCADE, - permission TEXT NOT NULL, - granted_at_unix INTEGER NOT NULL, - PRIMARY KEY (connection_id, permission) -); - -CREATE INDEX IF NOT EXISTS signer_connection_permission_grant_permission_idx -ON signer_connection_permission_grant (permission); - -CREATE TABLE IF NOT EXISTS signer_connection_relay ( - connection_id TEXT NOT NULL REFERENCES signer_connection (connection_id) ON DELETE CASCADE, - ordinal INTEGER NOT NULL, - relay_url TEXT NOT NULL, - PRIMARY KEY (connection_id, ordinal), - UNIQUE (connection_id, relay_url) -); - -CREATE INDEX IF NOT EXISTS signer_connection_relay_url_idx -ON signer_connection_relay (relay_url); - -CREATE TABLE IF NOT EXISTS signer_connection_auth_challenge ( - connection_id TEXT PRIMARY KEY REFERENCES signer_connection (connection_id) ON DELETE CASCADE, - auth_url TEXT NOT NULL, - required_at_unix INTEGER NOT NULL, - authorized_at_unix INTEGER -); - -CREATE TABLE IF NOT EXISTS signer_connection_pending_request ( - connection_id TEXT PRIMARY KEY REFERENCES signer_connection (connection_id) ON DELETE CASCADE, - request_message_json TEXT NOT NULL, - created_at_unix INTEGER NOT NULL -); - -CREATE TABLE IF NOT EXISTS signer_request_audit ( - request_id TEXT PRIMARY KEY, - connection_id TEXT NOT NULL REFERENCES signer_connection (connection_id) ON DELETE CASCADE, - method TEXT NOT NULL, - decision TEXT NOT NULL, - message TEXT, - created_at_unix INTEGER NOT NULL -); - -CREATE INDEX IF NOT EXISTS signer_request_audit_connection_id_idx -ON signer_request_audit (connection_id); - -CREATE INDEX IF NOT EXISTS signer_request_audit_created_at_idx -ON signer_request_audit (created_at_unix); diff --git a/migrations/signer/0001_publish_workflows.down.sql b/migrations/signer/0001_publish_workflows.down.sql @@ -1 +0,0 @@ -DROP TABLE IF EXISTS signer_publish_workflow; diff --git a/migrations/signer/0001_publish_workflows.up.sql b/migrations/signer/0001_publish_workflows.up.sql @@ -1,16 +0,0 @@ -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/migrations/signer/0002_client_metadata.down.sql b/migrations/signer/0002_client_metadata.down.sql @@ -1,2 +0,0 @@ -ALTER TABLE signer_connection -DROP COLUMN client_metadata_json; diff --git a/migrations/signer/0002_client_metadata.up.sql b/migrations/signer/0002_client_metadata.up.sql @@ -1,2 +0,0 @@ -ALTER TABLE signer_connection -ADD COLUMN client_metadata_json TEXT; diff --git a/src/app/backend.rs b/src/app/backend.rs @@ -1,416 +0,0 @@ -use crate::host_identity::RadrootsIdentityPublic; -use crate::signer::prelude::{ - RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability, - RadrootsNostrRemoteSessionSignerCapability, RadrootsNostrSignerAuthorizationOutcome, - RadrootsNostrSignerBackend, RadrootsNostrSignerBackendCapabilities, - RadrootsNostrSignerCapability, RadrootsNostrSignerConnectEvaluation, - RadrootsNostrSignerConnectionDraft, RadrootsNostrSignerConnectionId, - RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerConnectionStatus, - RadrootsNostrSignerError, RadrootsNostrSignerManager, RadrootsNostrSignerPendingRequest, - RadrootsNostrSignerPublishTransition, RadrootsNostrSignerPublishWorkflowRecord, - RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestDecision, - RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerSessionLookup, - RadrootsNostrSignerSignOutput, RadrootsNostrSignerWorkflowId, -}; -use nostr::{PublicKey, RelayUrl, UnsignedEvent}; -use radroots_nostr_connect::{Method, Request, message::RequestMessage, permission::Permissions}; - -use crate::app::MycSignerContext; -use crate::error::MycError; - -#[derive(Clone)] -pub struct MycSignerBackend { - signer: MycSignerContext, -} - -impl MycSignerBackend { - pub fn new(signer: MycSignerContext) -> Self { - Self { signer } - } - - fn manager(&self) -> Result<RadrootsNostrSignerManager, RadrootsNostrSignerError> { - self.signer - .load_signer_manager() - .map_err(convert_runtime_signer_error) - } - - fn configured_signer_identity(&self) -> RadrootsIdentityPublic { - self.signer.signer_public_identity() - } - - fn local_signer_capability(&self) -> RadrootsNostrLocalSignerCapability { - let public_identity = self.configured_signer_identity(); - RadrootsNostrLocalSignerCapability::new( - public_identity.id.to_final().into(), - public_identity, - RadrootsNostrLocalSignerAvailability::SecretBacked, - ) - } -} - -impl RadrootsNostrSignerBackend for MycSignerBackend { - fn signer_identity(&self) -> Result<Option<RadrootsIdentityPublic>, RadrootsNostrSignerError> { - Ok(Some(self.configured_signer_identity())) - } - - fn set_signer_identity( - &self, - signer_identity: RadrootsIdentityPublic, - ) -> Result<(), RadrootsNostrSignerError> { - let configured = self.configured_signer_identity(); - if configured.id != signer_identity.id - || configured.public_key_hex != signer_identity.public_key_hex - || configured.public_key_npub != signer_identity.public_key_npub - { - return Err(RadrootsNostrSignerError::InvalidState(format!( - "runtime-backed myc signer backend cannot switch signer identity from `{}` to `{}`", - configured.id, signer_identity.id - ))); - } - self.manager()?.set_signer_identity(signer_identity) - } - - fn capabilities( - &self, - ) -> Result<RadrootsNostrSignerBackendCapabilities, RadrootsNostrSignerError> { - let remote_sessions = self - .manager()? - .list_connections()? - .into_iter() - .filter(|record| record.status == RadrootsNostrSignerConnectionStatus::Active) - .map(|record| RadrootsNostrRemoteSessionSignerCapability::from(&record)) - .collect(); - Ok(RadrootsNostrSignerBackendCapabilities::new( - Some(self.local_signer_capability()), - remote_sessions, - )) - } - - fn list_connections( - &self, - ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { - self.manager()?.list_connections() - } - - fn get_connection( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { - self.manager()?.get_connection(connection_id) - } - - fn list_publish_workflows( - &self, - ) -> Result<Vec<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError> { - self.manager()?.list_publish_workflows() - } - - fn get_publish_workflow( - &self, - workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<Option<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError> { - self.manager()?.get_publish_workflow(workflow_id) - } - - fn find_connections_by_client_public_key( - &self, - client_public_key: &PublicKey, - ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { - self.manager()? - .find_connections_by_client_public_key(client_public_key) - } - - fn find_connection_by_connect_secret( - &self, - connect_secret: &str, - ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { - self.manager()? - .find_connection_by_connect_secret(connect_secret) - } - - fn lookup_session( - &self, - client_public_key: &PublicKey, - connect_secret: Option<&str>, - ) -> Result<RadrootsNostrSignerSessionLookup, RadrootsNostrSignerError> { - self.manager()? - .lookup_session(client_public_key, connect_secret) - } - - fn evaluate_connect_request( - &self, - client_public_key: PublicKey, - request: Request, - ) -> Result<RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerError> { - self.manager()? - .evaluate_connect_request(client_public_key, request) - } - - fn register_connection( - &self, - draft: RadrootsNostrSignerConnectionDraft, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.manager()?.register_connection(draft) - } - - fn set_granted_permissions( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - granted_permissions: Permissions, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.manager()? - .set_granted_permissions(connection_id, granted_permissions) - } - - fn approve_connection( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - granted_permissions: Permissions, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.manager()? - .approve_connection(connection_id, granted_permissions) - } - - fn reject_connection( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - reason: Option<String>, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.manager()?.reject_connection(connection_id, reason) - } - - fn revoke_connection( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - reason: Option<String>, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.manager()?.revoke_connection(connection_id, reason) - } - - fn update_relays( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - relays: Vec<RelayUrl>, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.manager()?.update_relays(connection_id, relays) - } - - fn require_auth_challenge( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - auth_url: &str, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.manager()? - .require_auth_challenge(connection_id, auth_url) - } - - fn set_pending_request( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - request_message: RequestMessage, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.manager()? - .set_pending_request(connection_id, request_message) - } - - fn authorize_auth_challenge( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerAuthorizationOutcome, RadrootsNostrSignerError> { - self.manager()?.authorize_auth_challenge(connection_id) - } - - fn restore_pending_auth_challenge( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - pending_request: RadrootsNostrSignerPendingRequest, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.manager()? - .restore_pending_auth_challenge(connection_id, pending_request) - } - - fn begin_connect_secret_publish_finalization( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> { - self.manager()? - .begin_connect_secret_publish_finalization(connection_id) - .map(RadrootsNostrSignerPublishTransition::begun) - } - - fn begin_auth_replay_publish_finalization( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> { - self.manager()? - .begin_auth_replay_publish_finalization(connection_id) - .map(RadrootsNostrSignerPublishTransition::begun) - } - - fn mark_publish_workflow_published( - &self, - workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> { - self.manager()? - .mark_publish_workflow_published(workflow_id) - .map(RadrootsNostrSignerPublishTransition::marked_published) - } - - fn finalize_publish_workflow( - &self, - workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> { - let connection = self.manager()?.finalize_publish_workflow(workflow_id)?; - Ok(RadrootsNostrSignerPublishTransition::finalized( - workflow_id.clone(), - connection, - )) - } - - fn cancel_publish_workflow( - &self, - workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> { - self.manager()? - .cancel_publish_workflow(workflow_id) - .map(RadrootsNostrSignerPublishTransition::cancelled) - } - - fn mark_authenticated( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.manager()?.mark_authenticated(connection_id) - } - - fn mark_connect_secret_consumed( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.manager()?.mark_connect_secret_consumed(connection_id) - } - - fn evaluate_request( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - request_message: RequestMessage, - ) -> Result<RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerError> { - self.manager()? - .evaluate_request(connection_id, request_message) - } - - fn evaluate_auth_replay_publish_workflow( - &self, - workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerError> { - self.manager()? - .evaluate_auth_replay_publish_workflow(workflow_id) - } - - fn record_request( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - request_id: &str, - method: Method, - decision: RadrootsNostrSignerRequestDecision, - message: Option<String>, - ) -> Result<RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerError> { - self.manager()? - .record_request(connection_id, request_id, method, decision, message) - } - - fn sign_unsigned_event( - &self, - unsigned_event: UnsignedEvent, - ) -> Result<RadrootsNostrSignerSignOutput, RadrootsNostrSignerError> { - let event = self - .signer - .signer_identity() - .sign_unsigned_event(unsigned_event, "myc signer backend event") - .map_err(|error| RadrootsNostrSignerError::Sign(error.to_string()))?; - Ok(RadrootsNostrSignerSignOutput::new( - RadrootsNostrSignerCapability::LocalAccount(Box::new(self.local_signer_capability())), - event, - )) - } -} - -fn convert_runtime_signer_error(error: MycError) -> RadrootsNostrSignerError { - match error { - MycError::SignerState(source) => source, - other => RadrootsNostrSignerError::InvalidState(other.to_string()), - } -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use crate::host_identity::RadrootsIdentity; - use crate::signer::prelude::{RadrootsNostrSignerBackend, RadrootsNostrSignerConnectionDraft}; - use nostr::Keys; - - use crate::app::MycRuntime; - fn write_identity(path: &std::path::Path, secret_key: &str) { - let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); - crate::identity_files::store_encrypted_identity(path, &identity).expect("save identity"); - } - - fn test_runtime() -> MycRuntime { - let temp = tempfile::tempdir().expect("tempdir").keep(); - let mut config = crate::config::test_config(PathBuf::from(&temp).as_path()); - config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json"); - config.paths.user_identity_path = PathBuf::from(&temp).join("user.json"); - write_identity( - &config.paths.signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_identity( - &config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - MycRuntime::bootstrap(config).expect("runtime") - } - - #[test] - fn runtime_backed_backend_projects_local_and_remote_capabilities() { - let runtime = test_runtime(); - let backend = runtime.signer_backend(); - - let initial = backend.capabilities().expect("capabilities"); - assert!( - initial - .local_signer - .expect("local signer capability") - .is_secret_backed() - ); - assert!(initial.remote_sessions.is_empty()); - - let connection = backend - .register_connection(RadrootsNostrSignerConnectionDraft::new( - Keys::generate().public_key(), - runtime.user_public_identity(), - )) - .expect("register connection"); - - let capabilities = backend.capabilities().expect("capabilities after approval"); - assert_eq!(capabilities.remote_sessions.len(), 1); - assert_eq!( - capabilities.remote_sessions[0].connection_id, - connection.connection_id - ); - } - - #[test] - fn runtime_backed_backend_rejects_signer_identity_drift() { - let runtime = test_runtime(); - let backend = runtime.signer_backend(); - let other_identity = RadrootsIdentity::generate().to_public(); - - let error = backend - .set_signer_identity(other_identity) - .expect_err("identity drift should be rejected"); - - assert!(error.to_string().contains("cannot switch signer identity")); - } -} diff --git a/src/app/mod.rs b/src/app/mod.rs @@ -1,134 +0,0 @@ -pub mod backend; -pub mod runtime; - -use crate::config::MycConfig; -use crate::error::MycError; - -pub use backend::MycSignerBackend; -pub use runtime::{MycRuntime, MycRuntimePaths, MycSignerContext, MycStartupSnapshot}; - -#[derive(Clone)] -pub struct MycApp { - runtime: MycRuntime, -} - -impl MycApp { - pub fn bootstrap(config: MycConfig) -> Result<Self, MycError> { - Ok(Self { - runtime: MycRuntime::bootstrap(config)?, - }) - } - - pub fn runtime(&self) -> &MycRuntime { - &self.runtime - } - - pub fn snapshot(&self) -> MycStartupSnapshot { - self.runtime.snapshot() - } - - pub async fn run(self) -> Result<(), MycError> { - self.runtime.run().await - } - - pub async fn run_until<F>(self, shutdown: F) -> Result<(), MycError> - where - F: std::future::Future<Output = ()>, - { - self.runtime.run_until(shutdown).await - } -} - -#[cfg(test)] -mod tests { - use crate::host_identity::RadrootsIdentity; - - use crate::config::MycSignerStateBackend; - - use super::MycApp; - - fn write_test_identity(path: &std::path::Path, secret_key: &str) { - let identity = - RadrootsIdentity::from_secret_key_str(secret_key).expect("identity from secret"); - crate::identity_files::store_encrypted_identity(path, &identity).expect("write identity"); - } - - #[test] - fn app_bootstrap_preserves_runtime_snapshot() { - let temp = tempfile::tempdir().expect("tempdir"); - let mut config = crate::config::test_config(temp.path()); - config.paths.signer_identity_path = temp.path().join("identity.json"); - config.paths.user_identity_path = temp.path().join("user.json"); - write_test_identity( - &config.paths.signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_test_identity( - &config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - - let app = MycApp::bootstrap(config).expect("bootstrap"); - let snapshot = app.snapshot(); - - assert!(snapshot.state_dir.ends_with("services/myc/test")); - assert!(snapshot.audit_dir.ends_with("audit")); - assert!( - snapshot - .signer_identity_path - .as_ref() - .expect("encrypted signer path") - .ends_with("identity.json") - ); - assert!( - snapshot - .user_identity_path - .as_ref() - .expect("encrypted user path") - .ends_with("user.json") - ); - assert_eq!( - snapshot.signer_identity_source.backend.as_str(), - "encrypted_file" - ); - assert_eq!( - snapshot.user_identity_source.backend.as_str(), - "encrypted_file" - ); - assert_eq!(snapshot.signer_state_backend.as_str(), "json_file"); - assert!(snapshot.signer_state_path.ends_with("signer-state.json")); - assert_eq!(snapshot.runtime_audit_backend.as_str(), "jsonl_file"); - assert!(snapshot.runtime_audit_path.ends_with("operations.jsonl")); - assert!(!snapshot.signer_identity_id.is_empty()); - assert!(!snapshot.signer_public_key_hex.is_empty()); - assert!(!snapshot.user_identity_id.is_empty()); - assert!(!snapshot.user_public_key_hex.is_empty()); - assert!(!snapshot.transport.enabled); - } - - #[test] - fn app_bootstrap_uses_backend_aware_signer_state_path() { - let temp = tempfile::tempdir().expect("tempdir"); - let mut config = crate::config::test_config(temp.path()); - config.paths.signer_identity_path = temp.path().join("identity.json"); - config.paths.user_identity_path = temp.path().join("user.json"); - config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; - config.persistence.runtime_audit_backend = crate::config::MycRuntimeAuditBackend::Sqlite; - write_test_identity( - &config.paths.signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_test_identity( - &config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - - let app = MycApp::bootstrap(config).expect("bootstrap"); - let snapshot = app.snapshot(); - - assert_eq!(snapshot.signer_state_backend.as_str(), "sqlite"); - assert!(snapshot.signer_state_path.ends_with("signer-state.sqlite")); - assert_eq!(snapshot.runtime_audit_backend.as_str(), "sqlite"); - assert!(snapshot.runtime_audit_path.ends_with("operations.sqlite")); - } -} diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -1,2026 +0,0 @@ -use std::fs; -use std::future::Future; -use std::net::SocketAddr; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; - -use super::backend::MycSignerBackend; -use crate::audit::{ - MycJsonlOperationAuditStore, MycOperationAuditKind, MycOperationAuditOutcome, - MycOperationAuditRecord, MycOperationAuditStore, -}; -use crate::audit_sqlite::MycSqliteOperationAuditStore; -use crate::config::{ - MycAuditConfig, MycConfig, MycIdentityBackend, MycIdentitySourceSpec, MycPersistenceConfig, - MycRuntimeAuditBackend, MycSignerStateBackend, MycTransportDeliveryPolicy, -}; -use crate::custody::{MycActiveIdentity, MycIdentityProvider}; -use crate::discovery::MycDiscoveryContext; -use crate::error::MycError; -use crate::host_identity::RadrootsIdentityPublic; -use crate::operability::{ - MycDeliveryOutboxStatusOutput, MycLiveMetricsHandle, MycLiveMetricsState, MycMetricsSnapshot, - server::run_observability_server, -}; -use crate::outbox::{ - MycDeliveryOutboxKind, MycDeliveryOutboxRecord, MycDeliveryOutboxStatus, MycDeliveryOutboxStore, -}; -use crate::outbox_sqlite::MycSqliteDeliveryOutboxStore; -use crate::policy::MycPolicyContext; -use crate::signer::prelude::{ - RadrootsNostrFileSignerStore, RadrootsNostrSignerApprovalRequirement, - RadrootsNostrSignerAuthState, RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerManager, - RadrootsNostrSignerPublishWorkflowKind, RadrootsNostrSignerPublishWorkflowRecord, - RadrootsNostrSignerPublishWorkflowState, RadrootsNostrSignerRequestAuditRecord, - RadrootsNostrSignerStore, RadrootsNostrSqliteSignerStore, -}; -use crate::transport::{ - MycNip46Service, MycNostrTransport, MycPublishOutcome, MycTransportSnapshot, -}; -use serde::Serialize; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MycRuntimePaths { - pub state_dir: PathBuf, - pub audit_dir: PathBuf, - pub signer_identity_path: PathBuf, - pub user_identity_path: PathBuf, - pub signer_state_path: PathBuf, - pub runtime_audit_path: PathBuf, - pub delivery_outbox_path: PathBuf, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycStartupSnapshot { - pub instance_name: String, - pub log_filter: String, - pub observability_enabled: bool, - pub observability_bind_addr: SocketAddr, - pub state_dir: PathBuf, - pub audit_dir: PathBuf, - #[serde(skip_serializing_if = "Option::is_none")] - pub signer_identity_path: Option<PathBuf>, - #[serde(skip_serializing_if = "Option::is_none")] - pub user_identity_path: Option<PathBuf>, - pub signer_identity_source: MycIdentitySourceSpec, - pub user_identity_source: MycIdentitySourceSpec, - pub signer_state_backend: MycSignerStateBackend, - pub signer_state_path: PathBuf, - pub runtime_audit_backend: MycRuntimeAuditBackend, - pub runtime_audit_path: PathBuf, - pub signer_identity_id: String, - pub signer_public_key_hex: String, - pub user_identity_id: String, - pub user_public_key_hex: String, - pub transport: MycTransportSnapshot, -} - -#[derive(Clone)] -pub struct MycSignerContext { - signer_identity_provider: MycIdentityProvider, - user_identity_provider: MycIdentityProvider, - signer_identity: MycActiveIdentity, - user_identity: MycActiveIdentity, - signer_store: Arc<dyn RadrootsNostrSignerStore>, - operation_audit_store: Arc<dyn MycOperationAuditStore>, - live_metrics: MycLiveMetricsHandle, - policy: MycPolicyContext, - connection_approval_requirement: RadrootsNostrSignerApprovalRequirement, -} - -#[derive(Clone)] -pub struct MycRuntime { - config: MycConfig, - paths: MycRuntimePaths, - signer: MycSignerContext, - transport: Option<MycNostrTransport>, - delivery_outbox_store: Arc<dyn MycDeliveryOutboxStore>, -} - -fn startup_identity_path(source: &MycIdentitySourceSpec) -> Option<PathBuf> { - match source.backend { - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ManagedAccount => source.path.clone(), - MycIdentityBackend::HostVault | MycIdentityBackend::ExternalCommand => None, - } -} - -fn format_startup_identity_path(path: Option<&Path>) -> String { - path.map(|path| path.display().to_string()) - .unwrap_or_default() -} - -impl MycRuntime { - pub fn bootstrap(config: MycConfig) -> Result<Self, MycError> { - config.validate()?; - - let paths = MycRuntimePaths::from_config(&config); - Self::prepare_filesystem_for(&paths)?; - let signer = MycSignerContext::bootstrap( - &paths, - &config.persistence, - config.audit.clone(), - MycPolicyContext::from_config(&config.policy)?, - Duration::from_secs(config.custody.external_command_timeout_secs), - config.paths.signer_identity_source(), - config.paths.user_identity_source(), - )?; - let transport = MycNostrTransport::bootstrap(&config.transport, &signer.signer_identity)?; - let delivery_outbox_store = Arc::new(MycSqliteDeliveryOutboxStore::open(&paths.state_dir)?); - let runtime = Self { - paths, - config, - signer, - transport, - delivery_outbox_store, - }; - Ok(runtime) - } - - pub fn paths(&self) -> &MycRuntimePaths { - &self.paths - } - - pub fn config(&self) -> &MycConfig { - &self.config - } - - pub fn signer_identity(&self) -> &MycActiveIdentity { - self.signer.signer_identity() - } - - pub fn signer_public_identity(&self) -> RadrootsIdentityPublic { - self.signer.signer_public_identity() - } - - pub fn user_identity(&self) -> &MycActiveIdentity { - self.signer.user_identity() - } - - pub fn user_public_identity(&self) -> RadrootsIdentityPublic { - self.signer.user_public_identity() - } - - pub fn signer_manager(&self) -> Result<RadrootsNostrSignerManager, MycError> { - self.signer.load_signer_manager() - } - - pub fn signer_backend(&self) -> MycSignerBackend { - MycSignerBackend::new(self.signer.clone()) - } - - pub fn transport(&self) -> Option<&MycNostrTransport> { - self.transport.as_ref() - } - - pub fn operation_audit_store(&self) -> Arc<dyn MycOperationAuditStore> { - self.signer.operation_audit_store() - } - - pub(crate) fn metrics_snapshot( - &self, - outbox_status: &MycDeliveryOutboxStatusOutput, - ) -> MycMetricsSnapshot { - self.signer.metrics_snapshot(outbox_status) - } - - pub fn delivery_outbox_store(&self) -> Arc<dyn MycDeliveryOutboxStore> { - self.delivery_outbox_store.clone() - } - - pub fn record_operation_audit(&self, record: &MycOperationAuditRecord) { - self.signer.record_operation_audit(record); - } - - pub(crate) fn signer_context(&self) -> MycSignerContext { - self.signer.clone() - } - - pub fn snapshot(&self) -> MycStartupSnapshot { - let signer_public = self.signer.signer_identity.to_public(); - let user_public = self.signer.user_identity.to_public(); - MycStartupSnapshot { - instance_name: self - .config - .runtime_context() - .context() - .instance() - .as_str() - .to_owned(), - log_filter: self.config.logging.filter.clone(), - observability_enabled: self.config.observability.enabled, - observability_bind_addr: self.config.observability.bind_addr, - state_dir: self.paths.state_dir.clone(), - audit_dir: self.paths.audit_dir.clone(), - signer_identity_path: startup_identity_path(self.signer.signer_identity_source()), - user_identity_path: startup_identity_path(self.signer.user_identity_source()), - signer_identity_source: self.signer.signer_identity_source().clone(), - user_identity_source: self.signer.user_identity_source().clone(), - signer_state_backend: self.config.persistence.signer_state_backend, - signer_state_path: self.paths.signer_state_path.clone(), - runtime_audit_backend: self.config.persistence.runtime_audit_backend, - runtime_audit_path: self.paths.runtime_audit_path.clone(), - signer_identity_id: signer_public.id.into_string(), - signer_public_key_hex: signer_public.public_key_hex, - user_identity_id: user_public.id.into_string(), - user_public_key_hex: user_public.public_key_hex, - transport: self - .transport - .as_ref() - .map(MycNostrTransport::snapshot) - .unwrap_or_else(MycTransportSnapshot::disabled), - } - } - - pub async fn run(self) -> Result<(), MycError> { - self.run_until(std::future::pending()).await - } - - pub async fn run_until<F>(self, shutdown: F) -> Result<(), MycError> - where - F: Future<Output = ()>, - { - let snapshot = self.snapshot(); - let signer_identity_path = - format_startup_identity_path(snapshot.signer_identity_path.as_deref()); - let user_identity_path = - format_startup_identity_path(snapshot.user_identity_path.as_deref()); - tracing::info!( - instance_name = %snapshot.instance_name, - state_dir = %snapshot.state_dir.display(), - audit_dir = %snapshot.audit_dir.display(), - signer_identity_path = %signer_identity_path, - user_identity_path = %user_identity_path, - signer_identity_backend = %snapshot.signer_identity_source.backend.as_str(), - user_identity_backend = %snapshot.user_identity_source.backend.as_str(), - signer_keyring_account_id = snapshot.signer_identity_source.keyring_account_id.as_deref().unwrap_or(""), - user_keyring_account_id = snapshot.user_identity_source.keyring_account_id.as_deref().unwrap_or(""), - signer_state_backend = snapshot.signer_state_backend.as_str(), - signer_state_path = %snapshot.signer_state_path.display(), - runtime_audit_backend = snapshot.runtime_audit_backend.as_str(), - runtime_audit_path = %snapshot.runtime_audit_path.display(), - signer_identity_id = %snapshot.signer_identity_id, - signer_public_key_hex = %snapshot.signer_public_key_hex, - user_identity_id = %snapshot.user_identity_id, - user_public_key_hex = %snapshot.user_public_key_hex, - observability_enabled = snapshot.observability_enabled, - observability_bind_addr = %snapshot.observability_bind_addr, - transport_enabled = snapshot.transport.enabled, - transport_relay_count = snapshot.transport.relay_count, - transport_connect_timeout_secs = snapshot.transport.connect_timeout_secs, - "myc runtime bootstrapped" - ); - self.recover_pending_delivery_jobs().await?; - let mut tasks = tokio::task::JoinSet::new(); - let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); - if let Some(transport) = self.transport.clone() { - let service = MycNip46Service::new( - self.signer_context(), - transport, - self.delivery_outbox_store(), - ); - let shutdown = observe_shutdown_signal(shutdown_rx.clone()); - tasks.spawn(async move { service.run_until(shutdown).await }); - } - if self.config.observability.enabled { - let runtime = self.clone(); - let shutdown = observe_shutdown_signal(shutdown_rx); - tasks.spawn(async move { run_observability_server(runtime, shutdown).await }); - } - - tokio::pin!(shutdown); - if tasks.is_empty() { - shutdown.await; - return Ok(()); - } - - tokio::select! { - _ = &mut shutdown => { - let _ = shutdown_tx.send(true); - drain_runtime_tasks(tasks).await - } - joined = tasks.join_next() => { - let _ = shutdown_tx.send(true); - let first_result = match joined { - Some(result) => result.map_err(|error| { - MycError::InvalidOperation(format!("myc runtime task failed: {error}")) - })?, - None => Ok(()), - }; - let remaining = drain_runtime_tasks(tasks).await; - first_result.and(remaining) - } - } - } - - fn prepare_filesystem_for(paths: &MycRuntimePaths) -> Result<(), MycError> { - fs::create_dir_all(&paths.state_dir).map_err(|source| MycError::CreateDir { - path: paths.state_dir.clone(), - source, - })?; - fs::create_dir_all(&paths.audit_dir).map_err(|source| MycError::CreateDir { - path: paths.audit_dir.clone(), - source, - })?; - Ok(()) - } - - async fn recover_pending_delivery_jobs(&self) -> Result<(), MycError> { - let mut queued_records = self - .delivery_outbox_store - .list_by_status(MycDeliveryOutboxStatus::Queued)?; - let published_records = self - .delivery_outbox_store - .list_by_status(MycDeliveryOutboxStatus::PublishedPendingFinalize)?; - let failed_logout_records = self - .delivery_outbox_store - .list_by_status(MycDeliveryOutboxStatus::Failed)? - .into_iter() - .filter(|record| record.kind == MycDeliveryOutboxKind::LogoutAcknowledgementPublish) - .collect::<Vec<_>>(); - if queued_records.is_empty() - && published_records.is_empty() - && failed_logout_records.is_empty() - { - if let Err(error) = self.ensure_no_orphaned_publish_workflows() { - self.record_delivery_recovery_summary( - MycOperationAuditOutcome::Rejected, - 0, - 0, - 0, - error.to_string(), - ); - return Err(error); - } - return Ok(()); - } - - queued_records.extend(published_records); - queued_records.extend(failed_logout_records); - queued_records.sort_by(|left, right| { - left.created_at_unix - .cmp(&right.created_at_unix) - .then_with(|| left.job_id.as_str().cmp(right.job_id.as_str())) - }); - - tracing::info!( - unfinished_delivery_job_count = queued_records.len(), - "starting myc delivery recovery" - ); - - let unfinished_delivery_job_count = queued_records.len(); - let mut finalized_job_count = 0usize; - let mut republished_job_count = 0usize; - let manager = self.signer_manager()?; - for record in queued_records { - match self.recover_delivery_outbox_record(&manager, record).await { - Ok(republished) => { - finalized_job_count += 1; - if republished { - republished_job_count += 1; - } - } - Err(error) => { - self.record_delivery_recovery_summary( - MycOperationAuditOutcome::Rejected, - unfinished_delivery_job_count, - finalized_job_count, - republished_job_count, - error.to_string(), - ); - return Err(error); - } - } - } - if let Err(error) = self.ensure_no_orphaned_publish_workflows() { - self.record_delivery_recovery_summary( - MycOperationAuditOutcome::Rejected, - unfinished_delivery_job_count, - finalized_job_count, - republished_job_count, - error.to_string(), - ); - return Err(error); - } - self.record_delivery_recovery_summary( - MycOperationAuditOutcome::Succeeded, - unfinished_delivery_job_count, - finalized_job_count, - republished_job_count, - format!( - "recovered {finalized_job_count}/{unfinished_delivery_job_count} delivery outbox job(s); republished {republished_job_count}" - ), - ); - - tracing::info!("completed myc delivery recovery"); - Ok(()) - } - - fn ensure_no_orphaned_publish_workflows(&self) -> Result<(), MycError> { - let workflows = self.signer_manager()?.list_publish_workflows()?; - if workflows.is_empty() { - return Ok(()); - } - - let remaining = workflows - .into_iter() - .map(|workflow| { - format!( - "{}:{}:{:?}", - workflow.workflow_id, workflow.connection_id, workflow.kind - ) - }) - .collect::<Vec<_>>() - .join(", "); - Err(MycError::InvalidOperation(format!( - "startup recovery found orphaned signer publish workflows with no recoverable outbox job: {remaining}" - ))) - } - - async fn recover_delivery_outbox_record( - &self, - manager: &RadrootsNostrSignerManager, - record: MycDeliveryOutboxRecord, - ) -> Result<bool, MycError> { - self.validate_outbox_workflow_expectations(&record)?; - let workflow = self.lookup_publish_workflow_for_record(manager, &record)?; - tracing::info!( - job_id = %record.job_id, - kind = ?record.kind, - status = ?record.status, - request_id = record.request_id.as_deref().unwrap_or(""), - attempt_id = record.attempt_id.as_deref().unwrap_or(""), - signer_publish_workflow_id = record - .signer_publish_workflow_id - .as_ref() - .map(ToString::to_string) - .unwrap_or_default(), - "recovering myc delivery outbox job" - ); - - match record.status { - MycDeliveryOutboxStatus::Queued | MycDeliveryOutboxStatus::Failed => { - if record.status == MycDeliveryOutboxStatus::Failed - && record.kind != MycDeliveryOutboxKind::LogoutAcknowledgementPublish - { - return Ok(false); - } - if record.signer_publish_workflow_id.is_some() && workflow.is_none() { - return Err(self.wrap_recovery_error( - &record, - MycError::InvalidOperation( - "delivery outbox job references a missing signer publish workflow before startup recovery publish" - .to_owned(), - ), - )); - } - if matches!( - workflow.as_ref().map(|workflow| workflow.state), - Some(RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize) - ) { - let publish_attempt_count = record.publish_attempt_count.max(1); - let published = self - .delivery_outbox_store - .mark_published_pending_finalize(&record.job_id, publish_attempt_count)?; - self.finalize_recovered_delivery_job( - manager, - published, - workflow.as_ref(), - None, - )?; - return Ok(false); - } - - let publish_outcome = self - .republish_recovered_outbox_event(&record) - .await - .map_err(|error| self.wrap_recovery_error(&record, error))?; - if let Some(workflow) = workflow.as_ref() { - manager - .mark_publish_workflow_published(&workflow.workflow_id) - .map_err(|error| { - self.wrap_recovery_error( - &record, - MycError::InvalidOperation(format!( - "failed to mark signer publish workflow as published during startup recovery: {error}" - )), - ) - })?; - } - let published_workflow = match record.signer_publish_workflow_id.as_ref() { - Some(workflow_id) => Some( - manager - .get_publish_workflow(workflow_id) - .map_err(MycError::from) - .and_then(|workflow| { - workflow.ok_or_else(|| { - MycError::InvalidOperation(format!( - "signer publish workflow `{workflow_id}` disappeared after startup recovery publish confirmation" - )) - }) - }) - .map_err(|error| self.wrap_recovery_error(&record, error))?, - ), - None => None, - }; - let published = self - .delivery_outbox_store - .mark_published_pending_finalize(&record.job_id, publish_outcome.attempt_count) - .map_err(|error| self.wrap_recovery_error(&record, error))?; - self.finalize_recovered_delivery_job( - manager, - published, - published_workflow.as_ref(), - Some(&publish_outcome), - )?; - Ok(true) - } - MycDeliveryOutboxStatus::PublishedPendingFinalize => { - self.finalize_recovered_delivery_job(manager, record, workflow.as_ref(), None)?; - Ok(false) - } - MycDeliveryOutboxStatus::Finalized => Ok(false), - } - } - - fn finalize_recovered_delivery_job( - &self, - manager: &RadrootsNostrSignerManager, - record: MycDeliveryOutboxRecord, - workflow: Option<&RadrootsNostrSignerPublishWorkflowRecord>, - publish_outcome: Option<&MycPublishOutcome>, - ) -> Result<(), MycError> { - if let Some(workflow) = workflow { - if workflow.state != RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize { - return Err(self.wrap_recovery_error( - &record, - MycError::InvalidOperation(format!( - "signer publish workflow `{}` is in `{:?}` instead of `published_pending_finalize` during startup recovery", - workflow.workflow_id, workflow.state - )), - )); - } - manager - .finalize_publish_workflow(&workflow.workflow_id) - .map_err(|error| { - self.wrap_recovery_error( - &record, - MycError::InvalidOperation(format!( - "failed to finalize signer publish workflow during startup recovery: {error}" - )), - ) - })?; - } else if record.kind == MycDeliveryOutboxKind::LogoutAcknowledgementPublish { - let connection = self.recovery_connection_record(manager, &record)?; - manager - .revoke_connection( - &connection.connection_id, - Some("NIP-46 logout acknowledged".to_owned()), - ) - .map_err(|error| { - self.wrap_recovery_error( - &record, - MycError::InvalidOperation(format!( - "failed to finalize NIP-46 logout during startup recovery: {error}" - )), - ) - })?; - } else { - self.ensure_record_is_already_finalized_without_workflow(manager, &record)?; - } - - let finalized_record = self - .delivery_outbox_store - .mark_finalized(&record.job_id) - .map_err(|error| self.wrap_recovery_error(&record, error))?; - self.record_recovery_success(&finalized_record, publish_outcome); - Ok(()) - } - - fn ensure_record_is_already_finalized_without_workflow( - &self, - manager: &RadrootsNostrSignerManager, - record: &MycDeliveryOutboxRecord, - ) -> Result<(), MycError> { - let Some(workflow_id) = record.signer_publish_workflow_id.as_ref() else { - return Ok(()); - }; - - match record.kind { - MycDeliveryOutboxKind::ListenerResponsePublish - | MycDeliveryOutboxKind::ConnectAcceptPublish => { - let connection = self.recovery_connection_record(manager, record)?; - if !connection.connect_secret_is_consumed() { - return Err(self.wrap_recovery_error( - record, - MycError::InvalidOperation(format!( - "delivery outbox job `{}` references consumed-secret workflow `{workflow_id}` but the connection secret is still reusable", - record.job_id - )), - )); - } - } - MycDeliveryOutboxKind::AuthReplayPublish => { - let connection = self.recovery_connection_record(manager, record)?; - if connection.auth_state != RadrootsNostrSignerAuthState::Authorized - || connection.pending_request.is_some() - { - return Err(self.wrap_recovery_error( - record, - MycError::InvalidOperation(format!( - "delivery outbox job `{}` references auth replay workflow `{workflow_id}` but the connection auth state is not finalized", - record.job_id - )), - )); - } - } - MycDeliveryOutboxKind::DiscoveryHandlerPublish => { - return Err(self.wrap_recovery_error( - record, - MycError::InvalidOperation(format!( - "discovery delivery outbox job `{}` unexpectedly references signer workflow `{workflow_id}`", - record.job_id - )), - )); - } - MycDeliveryOutboxKind::LogoutAcknowledgementPublish => { - return Err(self.wrap_recovery_error( - record, - MycError::InvalidOperation(format!( - "logout acknowledgement delivery outbox job `{}` unexpectedly references signer workflow `{workflow_id}`", - record.job_id - )), - )); - } - } - - Ok(()) - } - - fn recovery_connection_record( - &self, - manager: &RadrootsNostrSignerManager, - record: &MycDeliveryOutboxRecord, - ) -> Result<RadrootsNostrSignerConnectionRecord, MycError> { - let connection_id = record.connection_id.as_ref().ok_or_else(|| { - self.wrap_recovery_error( - record, - MycError::InvalidOperation( - "delivery outbox job is missing a connection id required for recovery" - .to_owned(), - ), - ) - })?; - manager.get_connection(connection_id)?.ok_or_else(|| { - self.wrap_recovery_error( - record, - MycError::InvalidOperation(format!( - "delivery outbox job references missing connection `{connection_id}`" - )), - ) - }) - } - - fn validate_outbox_workflow_expectations( - &self, - record: &MycDeliveryOutboxRecord, - ) -> Result<(), MycError> { - match record.kind { - MycDeliveryOutboxKind::DiscoveryHandlerPublish - | MycDeliveryOutboxKind::LogoutAcknowledgementPublish => { - if record.signer_publish_workflow_id.is_some() { - return Err(self.wrap_recovery_error( - record, - MycError::InvalidOperation(format!( - "{:?} delivery outbox jobs must not reference signer publish workflows", - record.kind - )), - )); - } - if record.kind == MycDeliveryOutboxKind::LogoutAcknowledgementPublish - && record.connection_id.is_none() - { - return Err(self.wrap_recovery_error( - record, - MycError::InvalidOperation( - "logout acknowledgement delivery outbox jobs require a connection id" - .to_owned(), - ), - )); - } - } - MycDeliveryOutboxKind::ConnectAcceptPublish - | MycDeliveryOutboxKind::AuthReplayPublish => { - if record.signer_publish_workflow_id.is_none() { - return Err(self.wrap_recovery_error( - record, - MycError::InvalidOperation( - "control delivery outbox jobs must reference signer publish workflows" - .to_owned(), - ), - )); - } - } - MycDeliveryOutboxKind::ListenerResponsePublish => {} - } - Ok(()) - } - - fn lookup_publish_workflow_for_record( - &self, - manager: &RadrootsNostrSignerManager, - record: &MycDeliveryOutboxRecord, - ) -> Result<Option<RadrootsNostrSignerPublishWorkflowRecord>, MycError> { - let Some(workflow_id) = record.signer_publish_workflow_id.as_ref() else { - return Ok(None); - }; - let workflow = manager.get_publish_workflow(workflow_id)?.map(|workflow| { - let kind_label = match record.kind { - MycDeliveryOutboxKind::ListenerResponsePublish - | MycDeliveryOutboxKind::ConnectAcceptPublish => { - RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization - } - MycDeliveryOutboxKind::AuthReplayPublish => { - RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization - } - MycDeliveryOutboxKind::DiscoveryHandlerPublish - | MycDeliveryOutboxKind::LogoutAcknowledgementPublish => unreachable!(), - }; - if workflow.kind != kind_label { - return Err(self.wrap_recovery_error( - record, - MycError::InvalidOperation(format!( - "delivery outbox job `{}` expects signer workflow kind `{kind_label:?}` but found `{:?}`", - record.job_id, workflow.kind - )), - )); - } - if let Some(connection_id) = record.connection_id.as_ref() - && &workflow.connection_id != connection_id - { - return Err(self.wrap_recovery_error( - record, - MycError::InvalidOperation(format!( - "delivery outbox job `{}` connection `{connection_id}` does not match signer workflow connection `{}`", - record.job_id, workflow.connection_id - )), - )); - } - Ok(workflow) - }); - workflow.transpose() - } - - async fn republish_recovered_outbox_event( - &self, - record: &MycDeliveryOutboxRecord, - ) -> Result<MycPublishOutcome, MycError> { - let signer_identity = self.recovery_publisher_identity(record)?; - MycNostrTransport::publish_event_once( - &signer_identity, - &record.relay_urls, - &self.config.transport, - recovery_operation_label(record.kind), - &record.event, - ) - .await - } - - fn recovery_publisher_identity( - &self, - record: &MycDeliveryOutboxRecord, - ) -> Result<MycActiveIdentity, MycError> { - if record.kind != MycDeliveryOutboxKind::DiscoveryHandlerPublish { - return Ok(self.signer_identity().clone()); - } - if record.event.pubkey == self.signer_identity().public_key() { - return Ok(self.signer_identity().clone()); - } - - let context = MycDiscoveryContext::from_runtime(self)?; - if record.event.pubkey != context.app_identity().public_key() { - return Err(self.wrap_recovery_error( - record, - MycError::InvalidOperation(format!( - "discovery delivery outbox job author `{}` does not match the configured signer or discovery app identity", - record.event.pubkey - )), - )); - } - Ok(context.app_identity().clone()) - } - - fn record_recovery_success( - &self, - outbox_record: &MycDeliveryOutboxRecord, - publish_outcome: Option<&MycPublishOutcome>, - ) { - let (relay_count, acknowledged_relay_count, summary, mut audit_record) = - match publish_outcome { - Some(publish_outcome) => ( - publish_outcome.relay_count, - publish_outcome.acknowledged_relay_count, - publish_outcome.relay_outcome_summary.clone(), - MycOperationAuditRecord::new( - recovery_operation_audit_kind(outbox_record.kind), - MycOperationAuditOutcome::Succeeded, - outbox_record.connection_id.as_ref(), - outbox_record.request_id.as_deref(), - publish_outcome.relay_count, - publish_outcome.acknowledged_relay_count, - publish_outcome.relay_outcome_summary.clone(), - ) - .with_delivery_details( - publish_outcome.delivery_policy, - publish_outcome.required_acknowledged_relay_count, - publish_outcome.attempt_count, - ), - ), - None => { - let relay_count = outbox_record.relay_urls.len(); - let required_acknowledged_relay_count = self - .required_acknowledged_relay_count(relay_count) - .unwrap_or_default(); - let summary = "startup recovery finalized previously published delivery job"; - ( - relay_count, - required_acknowledged_relay_count, - summary.to_owned(), - MycOperationAuditRecord::new( - recovery_operation_audit_kind(outbox_record.kind), - MycOperationAuditOutcome::Succeeded, - outbox_record.connection_id.as_ref(), - outbox_record.request_id.as_deref(), - relay_count, - required_acknowledged_relay_count, - summary.to_owned(), - ) - .with_delivery_details( - self.config.transport.delivery_policy, - required_acknowledged_relay_count, - outbox_record.publish_attempt_count.max(1), - ), - ) - } - }; - if let Some(attempt_id) = outbox_record.attempt_id.as_deref() { - audit_record = audit_record.with_attempt_id(attempt_id); - } - tracing::info!( - job_id = %outbox_record.job_id, - kind = ?outbox_record.kind, - relay_count, - acknowledged_relay_count, - summary = %summary, - "recovered myc delivery outbox job" - ); - self.record_operation_audit(&audit_record); - } - - fn record_delivery_recovery_summary( - &self, - outcome: MycOperationAuditOutcome, - unfinished_job_count: usize, - finalized_job_count: usize, - republished_job_count: usize, - summary: impl Into<String>, - ) { - let summary = summary.into(); - let record = MycOperationAuditRecord::new( - MycOperationAuditKind::DeliveryRecovery, - outcome, - None, - None, - unfinished_job_count, - finalized_job_count, - summary.clone(), - ); - tracing::info!( - outcome = ?outcome, - unfinished_job_count, - finalized_job_count, - republished_job_count, - summary = %summary, - "recorded myc delivery recovery summary" - ); - self.record_operation_audit(&record); - } - - fn required_acknowledged_relay_count(&self, relay_count: usize) -> Result<usize, MycError> { - match self.config.transport.delivery_policy { - MycTransportDeliveryPolicy::Any => Ok(1), - MycTransportDeliveryPolicy::All => Ok(relay_count), - MycTransportDeliveryPolicy::Quorum => { - let delivery_quorum = self.config.transport.delivery_quorum.ok_or_else(|| { - MycError::InvalidOperation( - "transport.delivery_quorum must be set when transport.delivery_policy is `quorum`" - .to_owned(), - ) - })?; - if delivery_quorum > relay_count { - return Err(MycError::InvalidOperation(format!( - "transport.delivery_quorum `{delivery_quorum}` cannot be satisfied by `{relay_count}` target relays" - ))); - } - Ok(delivery_quorum) - } - } - } - - fn wrap_recovery_error(&self, record: &MycDeliveryOutboxRecord, error: MycError) -> MycError { - let wrapped = MycError::InvalidOperation(format!( - "startup recovery failed for delivery outbox job `{}` ({:?}): {error}", - record.job_id, record.kind - )); - tracing::error!( - job_id = %record.job_id, - kind = ?record.kind, - status = ?record.status, - request_id = record.request_id.as_deref().unwrap_or(""), - attempt_id = record.attempt_id.as_deref().unwrap_or(""), - error = %wrapped, - "myc startup delivery recovery failed" - ); - wrapped - } -} - -fn recovery_operation_label(kind: MycDeliveryOutboxKind) -> &'static str { - match kind { - MycDeliveryOutboxKind::ListenerResponsePublish => "listener response recovery publish", - MycDeliveryOutboxKind::LogoutAcknowledgementPublish => { - "logout acknowledgement recovery publish" - } - MycDeliveryOutboxKind::ConnectAcceptPublish => "connect accept recovery publish", - MycDeliveryOutboxKind::AuthReplayPublish => "auth replay recovery publish", - MycDeliveryOutboxKind::DiscoveryHandlerPublish => "discovery handler recovery publish", - } -} - -fn recovery_operation_audit_kind(kind: MycDeliveryOutboxKind) -> MycOperationAuditKind { - match kind { - MycDeliveryOutboxKind::ListenerResponsePublish => { - MycOperationAuditKind::ListenerResponsePublish - } - MycDeliveryOutboxKind::LogoutAcknowledgementPublish => { - MycOperationAuditKind::ListenerResponsePublish - } - MycDeliveryOutboxKind::ConnectAcceptPublish => MycOperationAuditKind::ConnectAcceptPublish, - MycDeliveryOutboxKind::AuthReplayPublish => MycOperationAuditKind::AuthReplayPublish, - MycDeliveryOutboxKind::DiscoveryHandlerPublish => { - MycOperationAuditKind::DiscoveryHandlerPublish - } - } -} - -async fn drain_runtime_tasks( - mut tasks: tokio::task::JoinSet<Result<(), MycError>>, -) -> Result<(), MycError> { - let mut first_error = None; - while let Some(joined) = tasks.join_next().await { - match joined { - Ok(Ok(())) => {} - Ok(Err(error)) => { - if first_error.is_none() { - first_error = Some(error); - } - } - Err(error) => { - if first_error.is_none() { - first_error = Some(MycError::InvalidOperation(format!( - "myc runtime task failed: {error}" - ))); - } - } - } - } - - match first_error { - Some(error) => Err(error), - None => Ok(()), - } -} - -async fn observe_shutdown_signal(mut shutdown_rx: tokio::sync::watch::Receiver<bool>) { - loop { - if *shutdown_rx.borrow() { - break; - } - if shutdown_rx.changed().await.is_err() { - break; - } - } -} - -impl MycRuntimePaths { - pub(crate) fn audit_dir_for_state_dir(state_dir: &Path) -> PathBuf { - state_dir.join("audit") - } - - pub(crate) fn signer_state_path_for_backend( - state_dir: &Path, - backend: MycSignerStateBackend, - ) -> PathBuf { - state_dir.join(match backend { - MycSignerStateBackend::JsonFile => "signer-state.json", - MycSignerStateBackend::Sqlite => "signer-state.sqlite", - }) - } - - pub(crate) fn runtime_audit_path_for_backend( - audit_dir: &Path, - backend: MycRuntimeAuditBackend, - ) -> PathBuf { - audit_dir.join(match backend { - MycRuntimeAuditBackend::JsonlFile => "operations.jsonl", - MycRuntimeAuditBackend::Sqlite => "operations.sqlite", - }) - } - - pub(crate) fn delivery_outbox_path_for_state_dir(state_dir: &Path) -> PathBuf { - state_dir.join("delivery-outbox.sqlite") - } - - fn from_config(config: &MycConfig) -> Self { - let state_dir = config.paths.state_dir().to_path_buf(); - let audit_dir = Self::audit_dir_for_state_dir(&state_dir); - Self { - signer_identity_path: config.paths.signer_identity_path.clone(), - user_identity_path: config.paths.user_identity_path.clone(), - signer_state_path: Self::signer_state_path_for_backend( - &state_dir, - config.persistence.signer_state_backend, - ), - runtime_audit_path: Self::runtime_audit_path_for_backend( - &audit_dir, - config.persistence.runtime_audit_backend, - ), - delivery_outbox_path: Self::delivery_outbox_path_for_state_dir(&state_dir), - audit_dir, - state_dir, - } - } -} - -impl MycSignerContext { - pub fn signer_identity(&self) -> &MycActiveIdentity { - &self.signer_identity - } - - pub fn signer_identity_source(&self) -> &MycIdentitySourceSpec { - self.signer_identity_provider.source() - } - - pub fn signer_identity_provider(&self) -> &MycIdentityProvider { - &self.signer_identity_provider - } - - pub fn signer_public_identity(&self) -> RadrootsIdentityPublic { - self.signer_identity.to_public() - } - - pub fn user_identity(&self) -> &MycActiveIdentity { - &self.user_identity - } - - pub fn user_identity_source(&self) -> &MycIdentitySourceSpec { - self.user_identity_provider.source() - } - - pub fn user_identity_provider(&self) -> &MycIdentityProvider { - &self.user_identity_provider - } - - pub fn user_public_identity(&self) -> RadrootsIdentityPublic { - self.user_identity.to_public() - } - - pub fn load_signer_manager(&self) -> Result<RadrootsNostrSignerManager, MycError> { - Self::load_signer_manager_from_store(self.signer_store.clone()) - } - - pub fn operation_audit_store(&self) -> Arc<dyn MycOperationAuditStore> { - self.operation_audit_store.clone() - } - - pub fn record_signer_request_audit(&self, record: &RadrootsNostrSignerRequestAuditRecord) { - let mut metrics = self - .live_metrics - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - metrics.record_signer_request_audit(record); - } - - pub fn record_operation_audit(&self, record: &MycOperationAuditRecord) { - emit_operation_audit_trace(record); - match self.operation_audit_store.append(record) { - Ok(()) => { - let mut metrics = self - .live_metrics - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - metrics.record_runtime_operation(record); - } - Err(error) => { - tracing::error!( - operation = ?record.operation, - outcome = ?record.outcome, - relay_url = record.relay_url.as_deref().unwrap_or(""), - connection_id = record.connection_id.as_deref().unwrap_or(""), - request_id = record.request_id.as_deref().unwrap_or(""), - attempt_id = record.attempt_id.as_deref().unwrap_or(""), - delivery_policy = ?record.delivery_policy, - required_acknowledged_relay_count = record.required_acknowledged_relay_count.unwrap_or_default(), - publish_attempt_count = record.publish_attempt_count.unwrap_or_default(), - relay_count = record.relay_count, - acknowledged_relay_count = record.acknowledged_relay_count, - relay_outcome_summary = %record.relay_outcome_summary, - error = %error, - "failed to persist myc operation audit record" - ); - } - } - } - - pub fn metrics_snapshot( - &self, - outbox_status: &MycDeliveryOutboxStatusOutput, - ) -> MycMetricsSnapshot { - let metrics = self - .live_metrics - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - metrics.snapshot(outbox_status) - } - - pub fn connection_approval_requirement(&self) -> RadrootsNostrSignerApprovalRequirement { - self.connection_approval_requirement - } - - pub fn policy(&self) -> &MycPolicyContext { - &self.policy - } - - fn bootstrap( - paths: &MycRuntimePaths, - persistence: &MycPersistenceConfig, - audit_config: MycAuditConfig, - policy: MycPolicyContext, - external_command_timeout: Duration, - signer_identity_source: MycIdentitySourceSpec, - user_identity_source: MycIdentitySourceSpec, - ) -> Result<Self, MycError> { - let signer_identity_provider = MycIdentityProvider::from_source( - "signer", - signer_identity_source, - external_command_timeout, - )?; - let user_identity_provider = MycIdentityProvider::from_source( - "user", - user_identity_source, - external_command_timeout, - )?; - let signer_identity = signer_identity_provider.load_active_identity()?; - let user_identity = user_identity_provider.load_active_identity()?; - let signer_store = Self::build_signer_store(persistence, &paths.signer_state_path)?; - let operation_audit_store = - Self::build_operation_audit_store(persistence, &paths.audit_dir, audit_config)?; - let manager = Self::load_signer_manager_from_store(signer_store.clone())?; - let live_metrics = Arc::new(std::sync::Mutex::new(MycLiveMetricsState::from_records( - &manager.list_audit_records()?, - &operation_audit_store.list_all()?, - ))); - let configured_public = signer_identity.to_public(); - - match manager.signer_identity()? { - Some(existing) if existing.id != configured_public.id => { - return Err(MycError::SignerIdentityMismatch { - identity_path: paths.signer_identity_path.clone(), - state_path: paths.signer_state_path.clone(), - configured_identity_id: configured_public.id.to_string(), - persisted_identity_id: existing.id.to_string(), - }); - } - Some(_) => manager.set_signer_identity(configured_public.clone())?, - None => manager.set_signer_identity(configured_public.clone())?, - } - let stale_session_cleanup_count = policy.cleanup_stale_sessions(&manager)?; - if stale_session_cleanup_count > 0 { - tracing::info!( - stale_session_cleanup_count, - "cleaned stale trusted auth sessions during myc bootstrap" - ); - } - - Ok(Self { - signer_identity_provider, - user_identity_provider, - signer_identity, - user_identity, - signer_store, - operation_audit_store, - live_metrics, - connection_approval_requirement: policy.default_approval_requirement(), - policy, - }) - } - - fn build_signer_store( - persistence: &MycPersistenceConfig, - path: &Path, - ) -> Result<Arc<dyn RadrootsNostrSignerStore>, MycError> { - match persistence.signer_state_backend { - MycSignerStateBackend::JsonFile => { - Ok(Arc::new(RadrootsNostrFileSignerStore::new(path))) - } - MycSignerStateBackend::Sqlite => { - Ok(Arc::new(RadrootsNostrSqliteSignerStore::open(path)?)) - } - } - } - - fn build_operation_audit_store( - persistence: &MycPersistenceConfig, - audit_dir: &Path, - audit_config: MycAuditConfig, - ) -> Result<Arc<dyn MycOperationAuditStore>, MycError> { - match persistence.runtime_audit_backend { - MycRuntimeAuditBackend::JsonlFile => Ok(Arc::new(MycJsonlOperationAuditStore::new( - audit_dir, - audit_config, - ))), - MycRuntimeAuditBackend::Sqlite => Ok(Arc::new(MycSqliteOperationAuditStore::open( - audit_dir, - audit_config, - )?)), - } - } - - fn load_signer_manager_from_store( - store: Arc<dyn RadrootsNostrSignerStore>, - ) -> Result<RadrootsNostrSignerManager, MycError> { - Ok(RadrootsNostrSignerManager::new(store)?) - } -} - -fn emit_operation_audit_trace(record: &MycOperationAuditRecord) { - match record.outcome { - crate::audit::MycOperationAuditOutcome::Succeeded - | crate::audit::MycOperationAuditOutcome::Missing - | crate::audit::MycOperationAuditOutcome::Matched - | crate::audit::MycOperationAuditOutcome::Skipped => tracing::info!( - operation = ?record.operation, - outcome = ?record.outcome, - relay_url = record.relay_url.as_deref().unwrap_or(""), - connection_id = record.connection_id.as_deref().unwrap_or(""), - request_id = record.request_id.as_deref().unwrap_or(""), - attempt_id = record.attempt_id.as_deref().unwrap_or(""), - delivery_policy = ?record.delivery_policy, - required_acknowledged_relay_count = record.required_acknowledged_relay_count.unwrap_or_default(), - publish_attempt_count = record.publish_attempt_count.unwrap_or_default(), - relay_count = record.relay_count, - acknowledged_relay_count = record.acknowledged_relay_count, - relay_outcome_summary = %record.relay_outcome_summary, - "recorded myc operation audit" - ), - crate::audit::MycOperationAuditOutcome::Rejected - | crate::audit::MycOperationAuditOutcome::Restored - | crate::audit::MycOperationAuditOutcome::Unavailable - | crate::audit::MycOperationAuditOutcome::Drifted - | crate::audit::MycOperationAuditOutcome::Conflicted => tracing::warn!( - operation = ?record.operation, - outcome = ?record.outcome, - relay_url = record.relay_url.as_deref().unwrap_or(""), - connection_id = record.connection_id.as_deref().unwrap_or(""), - request_id = record.request_id.as_deref().unwrap_or(""), - attempt_id = record.attempt_id.as_deref().unwrap_or(""), - delivery_policy = ?record.delivery_policy, - required_acknowledged_relay_count = record.required_acknowledged_relay_count.unwrap_or_default(), - publish_attempt_count = record.publish_attempt_count.unwrap_or_default(), - relay_count = record.relay_count, - acknowledged_relay_count = record.acknowledged_relay_count, - relay_outcome_summary = %record.relay_outcome_summary, - "recorded myc operation audit" - ), - } -} - -#[cfg(test)] -mod tests { - use std::fs; - #[cfg(unix)] - use std::os::unix::fs::PermissionsExt; - use std::path::{Path, PathBuf}; - use std::sync::Arc; - - use crate::host_identity::RadrootsIdentity; - use crate::nostr_contract::{RadrootsNostrGenericEventBuilder, RadrootsNostrKind}; - use crate::signer::prelude::{ - RadrootsNostrFileSignerStore, RadrootsNostrSignerApprovalRequirement, - RadrootsNostrSignerAuthState, RadrootsNostrSignerConnectionDraft, - RadrootsNostrSignerManager, RadrootsNostrSqliteSignerStore, - }; - use nostr::PublicKey; - - use super::{MycRuntime, startup_identity_path}; - use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; - use crate::config::{MycIdentityBackend, MycRuntimeAuditBackend, MycSignerStateBackend}; - use crate::discovery::MycDiscoveryContext; - use crate::error::MycError; - use crate::outbox::{MycDeliveryOutboxKind, MycDeliveryOutboxRecord, MycDeliveryOutboxStatus}; - - fn write_test_identity(path: &std::path::Path, secret_key: &str) { - let identity = - RadrootsIdentity::from_secret_key_str(secret_key).expect("identity from secret"); - crate::identity_files::store_encrypted_identity(path, &identity).expect("write identity"); - } - - fn write_external_command_helper(path: &std::path::Path, secret_key: &str) -> RadrootsIdentity { - let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); - let identity_json = - serde_json::to_string(&identity.to_public()).expect("serialize public identity"); - let script = format!( - "#!/bin/sh\nrequest=\"$(cat)\"\ncase \"$request\" in\n *'\"operation\":\"describe\"'*) printf '%s' '{{\"identity\":{identity_json}}}' ;;\n *) printf '%s' '{{\"error\":\"unsupported operation\"}}' ;;\nesac\n" - ); - fs::write(path, script).expect("write helper"); - #[cfg(unix)] - { - let mut permissions = fs::metadata(path).expect("metadata").permissions(); - permissions.set_mode(0o755); - fs::set_permissions(path, permissions).expect("set permissions"); - } - identity - } - - #[test] - fn bootstrap_creates_runtime_directories() { - let temp = tempfile::tempdir().expect("tempdir"); - let mut config = crate::config::test_config(temp.path()); - config.paths.signer_identity_path = temp.path().join("identity.json"); - config.paths.user_identity_path = temp.path().join("user.json"); - write_test_identity( - &config.paths.signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_test_identity( - &config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - - let runtime = MycRuntime::bootstrap(config).expect("runtime"); - assert!(runtime.paths().state_dir.is_dir()); - assert!(runtime.paths().audit_dir.is_dir()); - assert_eq!( - runtime.paths().signer_identity_path, - temp.path().join("identity.json") - ); - assert_eq!( - runtime.paths().user_identity_path, - temp.path().join("user.json") - ); - assert!( - runtime - .paths() - .signer_state_path - .ends_with("signer-state.json") - ); - assert!(runtime.paths().signer_state_path.is_file()); - assert!( - runtime - .paths() - .delivery_outbox_path - .ends_with("delivery-outbox.sqlite") - ); - assert!(runtime.paths().delivery_outbox_path.is_file()); - assert!( - runtime - .delivery_outbox_store() - .list_all() - .expect("list outbox jobs") - .is_empty() - ); - assert_eq!( - runtime - .signer_manager() - .expect("manager") - .signer_identity() - .expect("signer identity") - .expect("configured signer") - .id - .to_string(), - runtime.snapshot().signer_identity_id - ); - assert_eq!( - runtime.user_identity().public_key_hex(), - runtime.snapshot().user_public_key_hex - ); - assert!(!runtime.snapshot().transport.enabled); - } - - #[test] - fn bootstrap_rejects_invalid_config() { - let mut config = crate::config::test_config(Path::new("/tmp/radroots-myc-invalid")); - config.logging.filter.clear(); - - let err = match MycRuntime::bootstrap(config) { - Ok(_) => panic!("expected invalid config error"), - Err(err) => err, - }; - assert!(err.to_string().contains("logging.filter")); - } - - #[test] - fn bootstrap_rejects_mismatched_persisted_signer_identity() { - let temp = tempfile::tempdir().expect("tempdir"); - let mut config = crate::config::test_config(temp.path()); - let identity_path = temp.path().join("identity.json"); - let user_path = temp.path().join("user.json"); - write_test_identity( - &identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_test_identity( - &user_path, - "3333333333333333333333333333333333333333333333333333333333333333", - ); - - let store_identity = RadrootsIdentity::from_secret_key_str( - "2222222222222222222222222222222222222222222222222222222222222222", - ) - .expect("second identity"); - let store = Arc::new(RadrootsNostrFileSignerStore::new( - config.paths.state_dir().join("signer-state.json"), - )); - let manager = RadrootsNostrSignerManager::new(store).expect("manager"); - manager - .set_signer_identity(store_identity.to_public()) - .expect("persist signer"); - - config.paths.signer_identity_path = identity_path; - config.paths.user_identity_path = user_path; - - let err = match MycRuntime::bootstrap(config) { - Ok(_) => panic!("expected identity mismatch"), - Err(err) => err, - }; - assert!(matches!(err, MycError::SignerIdentityMismatch { .. })); - } - - #[test] - fn bootstrap_keeps_signer_and_user_identities_distinct() { - let temp = tempfile::tempdir().expect("tempdir"); - let mut config = crate::config::test_config(temp.path()); - config.paths.signer_identity_path = temp.path().join("signer.json"); - config.paths.user_identity_path = temp.path().join("user.json"); - write_test_identity( - &config.paths.signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_test_identity( - &config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - - let runtime = MycRuntime::bootstrap(config).expect("runtime"); - - assert_ne!( - runtime.signer_public_identity().public_key_hex, - runtime.user_public_identity().public_key_hex - ); - assert_ne!( - runtime.snapshot().signer_identity_id, - runtime.snapshot().user_identity_id - ); - } - - #[test] - fn bootstrap_cleans_stale_trusted_authorized_sessions() { - let temp = tempfile::tempdir().expect("tempdir"); - let mut config = crate::config::test_config(temp.path()); - config.paths.signer_identity_path = temp.path().join("signer.json"); - config.paths.user_identity_path = temp.path().join("user.json"); - config.policy.auth_url = Some("https://auth.example/challenge".to_owned()); - config.policy.auth_authorized_ttl_secs = Some(1); - let client_public_key = - PublicKey::parse("4545454545454545454545454545454545454545454545454545454545454545") - .expect("client public key"); - config.policy.trusted_client_pubkeys = vec![client_public_key.to_hex()]; - write_test_identity( - &config.paths.signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_test_identity( - &config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - - let runtime = MycRuntime::bootstrap(config.clone()).expect("runtime"); - let manager = runtime.signer_manager().expect("manager"); - let connection = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new( - client_public_key, - runtime.user_public_identity(), - ) - .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::NotRequired), - ) - .expect("register connection"); - manager - .require_auth_challenge( - &connection.connection_id, - config.policy.auth_url.as_deref().expect("auth url"), - ) - .expect("require auth"); - manager - .authorize_auth_challenge(&connection.connection_id) - .expect("authorize auth"); - - std::thread::sleep(std::time::Duration::from_secs(2)); - drop(runtime); - - let runtime = MycRuntime::bootstrap(config).expect("runtime restart"); - let reloaded = runtime - .signer_manager() - .expect("manager") - .get_connection(&connection.connection_id) - .expect("load connection") - .expect("connection"); - - assert_eq!(reloaded.auth_state, RadrootsNostrSignerAuthState::Pending); - assert!(reloaded.auth_challenge.is_some()); - } - - #[test] - fn bootstrap_prepares_transport_when_enabled() { - let temp = tempfile::tempdir().expect("tempdir"); - let mut config = crate::config::test_config(temp.path()); - config.paths.signer_identity_path = temp.path().join("signer.json"); - config.paths.user_identity_path = temp.path().join("user.json"); - config.transport.enabled = true; - config.transport.connect_timeout_secs = 15; - config.transport.relays = vec!["wss://relay.example.com".to_owned()]; - write_test_identity( - &config.paths.signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_test_identity( - &config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - - let runtime = MycRuntime::bootstrap(config).expect("runtime"); - - assert!(runtime.transport().is_some()); - assert!(runtime.snapshot().transport.enabled); - assert_eq!(runtime.snapshot().transport.relay_count, 1); - assert_eq!(runtime.snapshot().transport.connect_timeout_secs, 15); - } - - #[tokio::test] - async fn bootstrap_prepares_signerless_transport_for_external_command_backend() { - let temp = tempfile::tempdir().expect("tempdir"); - let helper_path = temp.path().join("signer-helper.sh"); - let helper_identity = write_external_command_helper( - &helper_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - let mut config = crate::config::test_config(temp.path()); - config.paths.signer_identity_backend = MycIdentityBackend::ExternalCommand; - config.paths.signer_identity_path = helper_path; - config.paths.user_identity_path = temp.path().join("user.json"); - config.transport.enabled = true; - config.transport.connect_timeout_secs = 15; - config.transport.relays = vec!["wss://relay.example.com".to_owned()]; - write_test_identity( - &config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - - let runtime = MycRuntime::bootstrap(config).expect("runtime"); - - assert!(runtime.transport().is_some()); - assert!( - !runtime - .transport() - .expect("transport") - .client() - .has_signer() - .await - ); - assert_eq!( - runtime.signer_identity().public_key_hex(), - helper_identity.public_key_hex() - ); - } - - #[tokio::test] - async fn discovery_context_uses_signerless_client_for_external_command_app_identity() { - let temp = tempfile::tempdir().expect("tempdir"); - let helper_path = temp.path().join("discovery-helper.sh"); - let helper_identity = write_external_command_helper( - &helper_path, - "6666666666666666666666666666666666666666666666666666666666666666", - ); - let mut config = crate::config::test_config(temp.path()); - config.paths.signer_identity_path = temp.path().join("signer.json"); - config.paths.user_identity_path = temp.path().join("user.json"); - config.discovery.enabled = true; - config.discovery.domain = Some("signer.example.com".to_owned()); - config.discovery.public_relays = vec!["wss://relay.example.com".to_owned()]; - config.discovery.publish_relays = vec!["wss://relay.example.com".to_owned()]; - config.discovery.nostrconnect_url_template = - Some("https://signer.example.com/connect?uri=<nostrconnect>".to_owned()); - config.discovery.app_identity_backend = Some(MycIdentityBackend::ExternalCommand); - config.discovery.app_identity_path = Some(helper_path); - write_test_identity( - &config.paths.signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_test_identity( - &config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - - let runtime = MycRuntime::bootstrap(config).expect("runtime"); - let context = MycDiscoveryContext::from_runtime(&runtime).expect("discovery context"); - - assert!(!context.app_identity().nostr_client().has_signer().await); - assert_eq!( - context.app_identity().public_key_hex(), - helper_identity.public_key_hex() - ); - } - - #[test] - fn bootstrap_supports_sqlite_signer_state_backend() { - let temp = tempfile::tempdir().expect("tempdir"); - let mut config = crate::config::test_config(temp.path()); - config.paths.signer_identity_path = temp.path().join("signer.json"); - config.paths.user_identity_path = temp.path().join("user.json"); - config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; - write_test_identity( - &config.paths.signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_test_identity( - &config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - - let runtime = MycRuntime::bootstrap(config).expect("runtime"); - - assert!( - runtime - .paths() - .signer_state_path - .ends_with("signer-state.sqlite") - ); - assert!(runtime.paths().signer_state_path.is_file()); - assert!(runtime.paths().delivery_outbox_path.is_file()); - } - - #[test] - fn bootstrap_rejects_mismatched_persisted_sqlite_signer_identity() { - let temp = tempfile::tempdir().expect("tempdir"); - let mut config = crate::config::test_config(temp.path()); - let identity_path = temp.path().join("identity.json"); - let user_path = temp.path().join("user.json"); - write_test_identity( - &identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_test_identity( - &user_path, - "3333333333333333333333333333333333333333333333333333333333333333", - ); - - let store_identity = RadrootsIdentity::from_secret_key_str( - "2222222222222222222222222222222222222222222222222222222222222222", - ) - .expect("second identity"); - let store = Arc::new( - RadrootsNostrSqliteSignerStore::open( - config.paths.state_dir().join("signer-state.sqlite"), - ) - .expect("open sqlite store"), - ); - let manager = RadrootsNostrSignerManager::new(store).expect("manager"); - manager - .set_signer_identity(store_identity.to_public()) - .expect("persist signer"); - - config.paths.signer_identity_path = identity_path; - config.paths.user_identity_path = user_path; - config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; - - let err = match MycRuntime::bootstrap(config) { - Ok(_) => panic!("expected identity mismatch"), - Err(err) => err, - }; - assert!(matches!(err, MycError::SignerIdentityMismatch { .. })); - } - - #[test] - fn bootstrap_supports_sqlite_operation_audit_backend() { - let temp = tempfile::tempdir().expect("tempdir"); - let mut config = crate::config::test_config(temp.path()); - config.paths.signer_identity_path = temp.path().join("signer.json"); - config.paths.user_identity_path = temp.path().join("user.json"); - config.persistence.runtime_audit_backend = MycRuntimeAuditBackend::Sqlite; - write_test_identity( - &config.paths.signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_test_identity( - &config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - - let runtime = MycRuntime::bootstrap(config).expect("runtime"); - runtime.record_operation_audit(&MycOperationAuditRecord::new( - MycOperationAuditKind::ListenerResponsePublish, - MycOperationAuditOutcome::Succeeded, - None, - Some("request-1"), - 1, - 1, - "relay acknowledged publish", - )); - - let records = runtime - .operation_audit_store() - .list() - .expect("list runtime audit"); - assert_eq!(records.len(), 1); - assert!( - runtime - .paths() - .audit_dir - .join("operations.sqlite") - .is_file() - ); - assert!(runtime.paths().delivery_outbox_path.is_file()); - } - - #[test] - fn startup_identity_path_reporting_matches_backend_sources() { - let mut config = crate::config::test_config(Path::new("/tmp/radroots-myc-reporting")); - config.paths.signer_identity_backend = MycIdentityBackend::HostVault; - config.paths.signer_identity_keyring_account_id = - Some("1111111111111111111111111111111111111111111111111111111111111111".to_owned()); - config.paths.signer_identity_profile_path = Some(PathBuf::from("/tmp/signer-profile.json")); - config.paths.user_identity_backend = MycIdentityBackend::ManagedAccount; - config.paths.user_identity_path = PathBuf::from("/tmp/user-accounts.json"); - - assert_eq!( - startup_identity_path(&config.paths.signer_identity_source()), - None - ); - assert_eq!( - startup_identity_path(&config.paths.user_identity_source()), - Some(PathBuf::from("/tmp/user-accounts.json")) - ); - - config.paths.user_identity_backend = MycIdentityBackend::ExternalCommand; - config.paths.user_identity_path = PathBuf::from("/usr/local/libexec/myc-user-helper"); - assert_eq!( - startup_identity_path(&config.paths.user_identity_source()), - None - ); - } - - #[tokio::test] - async fn startup_recovery_rejects_orphaned_signer_publish_workflow() { - let temp = tempfile::tempdir().expect("tempdir"); - let mut config = crate::config::test_config(temp.path()); - config.paths.signer_identity_path = temp.path().join("signer.json"); - config.paths.user_identity_path = temp.path().join("user.json"); - write_test_identity( - &config.paths.signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_test_identity( - &config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - - let runtime = MycRuntime::bootstrap(config).expect("runtime"); - let client_identity = RadrootsIdentity::from_secret_key_str( - "7777777777777777777777777777777777777777777777777777777777777777", - ) - .expect("client identity"); - let manager = runtime.signer_manager().expect("manager"); - let connection = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new( - client_identity.public_key(), - runtime.user_public_identity(), - ) - .with_connect_secret("orphan-secret") - .with_relays(vec!["wss://relay.example.com".parse().expect("relay url")]) - .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::NotRequired), - ) - .expect("register connection"); - let workflow = manager - .begin_connect_secret_publish_finalization(&connection.connection_id) - .expect("begin workflow"); - - let error = runtime - .recover_pending_delivery_jobs() - .await - .expect_err("orphaned workflow should fail recovery"); - let message = error.to_string(); - assert!(message.contains("orphaned signer publish workflows")); - assert!(message.contains(workflow.workflow_id.as_str())); - } - - #[tokio::test] - async fn startup_recovery_finalizes_published_connect_secret_workflow() { - let temp = tempfile::tempdir().expect("tempdir"); - let mut config = crate::config::test_config(temp.path()); - config.paths.signer_identity_path = temp.path().join("signer.json"); - config.paths.user_identity_path = temp.path().join("user.json"); - write_test_identity( - &config.paths.signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_test_identity( - &config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - - let runtime = MycRuntime::bootstrap(config).expect("runtime"); - let client_identity = RadrootsIdentity::from_secret_key_str( - "7777777777777777777777777777777777777777777777777777777777777777", - ) - .expect("client identity"); - let manager = runtime.signer_manager().expect("manager"); - let connection = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new( - client_identity.public_key(), - runtime.user_public_identity(), - ) - .with_connect_secret("recovery-secret") - .with_relays(vec!["wss://relay.example.com".parse().expect("relay url")]) - .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::NotRequired), - ) - .expect("register connection"); - let workflow = manager - .begin_connect_secret_publish_finalization(&connection.connection_id) - .expect("begin workflow"); - manager - .mark_publish_workflow_published(&workflow.workflow_id) - .expect("mark workflow published"); - - let event = runtime - .signer_identity() - .sign_protocol_event_builder( - RadrootsNostrGenericEventBuilder::new(RadrootsNostrKind::Custom(24133), "recovery"), - "recovery test", - ) - .expect("sign event"); - let outbox_record = MycDeliveryOutboxRecord::new( - MycDeliveryOutboxKind::ListenerResponsePublish, - event, - vec!["wss://relay.example.com".parse().expect("relay url")], - ) - .expect("outbox record") - .with_connection_id(&connection.connection_id) - .with_request_id("recovery-request") - .with_signer_publish_workflow_id(&workflow.workflow_id); - runtime - .delivery_outbox_store() - .enqueue(&outbox_record) - .expect("enqueue outbox"); - runtime - .delivery_outbox_store() - .mark_published_pending_finalize(&outbox_record.job_id, 1) - .expect("mark outbox published"); - - runtime - .recover_pending_delivery_jobs() - .await - .expect("recovery should succeed"); - - let connection = runtime - .signer_manager() - .expect("manager") - .get_connection(&connection.connection_id) - .expect("get connection") - .expect("stored connection"); - assert!(connection.connect_secret_is_consumed()); - assert!( - runtime - .signer_manager() - .expect("manager") - .list_publish_workflows() - .expect("list workflows") - .is_empty() - ); - let outbox_records = runtime - .delivery_outbox_store() - .list_all() - .expect("list outbox"); - assert_eq!(outbox_records.len(), 1); - assert_eq!(outbox_records[0].status, MycDeliveryOutboxStatus::Finalized); - assert!(outbox_records[0].finalized_at_unix.is_some()); - let audit_records = runtime.operation_audit_store().list().expect("list audit"); - assert_eq!(audit_records.len(), 2); - assert_eq!( - audit_records[0].operation, - MycOperationAuditKind::ListenerResponsePublish - ); - assert_eq!( - audit_records[0].outcome, - MycOperationAuditOutcome::Succeeded - ); - assert_eq!( - audit_records[0].request_id.as_deref(), - Some("recovery-request") - ); - assert_eq!( - audit_records[1].operation, - MycOperationAuditKind::DeliveryRecovery - ); - assert_eq!( - audit_records[1].outcome, - MycOperationAuditOutcome::Succeeded - ); - } - - #[tokio::test] - async fn startup_recovery_rejects_queued_job_with_missing_signer_workflow() { - let temp = tempfile::tempdir().expect("tempdir"); - let mut config = crate::config::test_config(temp.path()); - config.paths.signer_identity_path = temp.path().join("signer.json"); - config.paths.user_identity_path = temp.path().join("user.json"); - write_test_identity( - &config.paths.signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_test_identity( - &config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - - let runtime = MycRuntime::bootstrap(config).expect("runtime"); - let client_identity = RadrootsIdentity::from_secret_key_str( - "7777777777777777777777777777777777777777777777777777777777777777", - ) - .expect("client identity"); - let manager = runtime.signer_manager().expect("manager"); - let connection = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new( - client_identity.public_key(), - runtime.user_public_identity(), - ) - .with_connect_secret("missing-workflow-secret") - .with_relays(vec!["wss://relay.example.com".parse().expect("relay url")]) - .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::NotRequired), - ) - .expect("register connection"); - let workflow = manager - .begin_connect_secret_publish_finalization(&connection.connection_id) - .expect("begin workflow"); - let event = runtime - .signer_identity() - .sign_protocol_event_builder( - RadrootsNostrGenericEventBuilder::new( - RadrootsNostrKind::Custom(24133), - "queued-recovery", - ), - "queued recovery test", - ) - .expect("sign event"); - let outbox_record = MycDeliveryOutboxRecord::new( - MycDeliveryOutboxKind::ListenerResponsePublish, - event, - vec!["wss://relay.example.com".parse().expect("relay url")], - ) - .expect("outbox record") - .with_connection_id(&connection.connection_id) - .with_request_id("queued-missing-workflow") - .with_signer_publish_workflow_id(&workflow.workflow_id); - runtime - .delivery_outbox_store() - .enqueue(&outbox_record) - .expect("enqueue outbox"); - manager - .cancel_publish_workflow(&workflow.workflow_id) - .expect("cancel workflow"); - - let error = runtime - .recover_pending_delivery_jobs() - .await - .expect_err("queued job with missing workflow should fail recovery"); - assert!( - error - .to_string() - .contains("missing signer publish workflow before startup recovery publish") - ); - } -} diff --git a/src/audit.rs b/src/audit.rs @@ -1,1074 +0,0 @@ -use std::collections::VecDeque; -use std::fs::{self, OpenOptions}; -use std::io::{BufRead, BufReader, Write}; -use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use crate::signer::prelude::RadrootsNostrSignerConnectionId; -use serde::{Deserialize, Serialize}; - -use crate::config::MycAuditConfig; -use crate::config::MycTransportDeliveryPolicy; -use crate::error::MycError; - -const MYC_OPERATION_AUDIT_FILE_NAME: &str = "operations.jsonl"; -const MYC_OPERATION_AUDIT_ARCHIVE_PREFIX: &str = "operations."; -const MYC_OPERATION_AUDIT_ARCHIVE_SUFFIX: &str = ".jsonl"; -const MYC_OPERATION_AUDIT_INDEX_DIR_NAME: &str = "index"; -const MYC_OPERATION_AUDIT_INDEX_TMP_DIR_NAME: &str = "index.tmp"; -const MYC_OPERATION_AUDIT_ATTEMPTS_DIR_NAME: &str = "attempts"; -const MYC_OPERATION_AUDIT_LATEST_DIR_NAME: &str = "latest"; -const MYC_OPERATION_AUDIT_LATEST_SUFFIX: &str = ".attempt"; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MycOperationAuditKind { - DeliveryRecovery, - ListenerResponsePublish, - ConnectAcceptPublish, - AuthReplayPublish, - AuthReplayRestore, - DiscoveryHandlerFetch, - DiscoveryHandlerPublish, - DiscoveryHandlerCompare, - DiscoveryHandlerRefresh, - DiscoveryHandlerRepair, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MycOperationAuditOutcome { - Succeeded, - Rejected, - Restored, - Unavailable, - Missing, - Matched, - Drifted, - Conflicted, - Skipped, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MycOperationAuditRecord { - pub recorded_at_unix: u64, - pub operation: MycOperationAuditKind, - pub outcome: MycOperationAuditOutcome, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub relay_url: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub connection_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub request_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub attempt_id: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub planned_repair_relays: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub blocked_relays: Vec<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub blocked_reason: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delivery_policy: Option<MycTransportDeliveryPolicy>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub required_acknowledged_relay_count: Option<usize>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub publish_attempt_count: Option<usize>, - pub relay_count: usize, - pub acknowledged_relay_count: usize, - pub relay_outcome_summary: String, -} - -pub trait MycOperationAuditStore: Send + Sync { - fn config(&self) -> &MycAuditConfig; - fn append(&self, record: &MycOperationAuditRecord) -> Result<(), MycError>; - fn list(&self) -> Result<Vec<MycOperationAuditRecord>, MycError> { - self.list_with_limit(self.config().default_read_limit) - } - fn list_all(&self) -> Result<Vec<MycOperationAuditRecord>, MycError>; - fn list_with_limit(&self, limit: usize) -> Result<Vec<MycOperationAuditRecord>, MycError>; - fn list_for_connection( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<Vec<MycOperationAuditRecord>, MycError> { - self.list_for_connection_with_limit(connection_id, self.config().default_read_limit) - } - fn list_for_connection_with_limit( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - limit: usize, - ) -> Result<Vec<MycOperationAuditRecord>, MycError>; - fn list_for_attempt_id( - &self, - attempt_id: &str, - ) -> Result<Vec<MycOperationAuditRecord>, MycError> { - self.list_for_attempt_id_with_limit(attempt_id, usize::MAX) - } - fn list_for_attempt_id_with_limit( - &self, - attempt_id: &str, - limit: usize, - ) -> Result<Vec<MycOperationAuditRecord>, MycError>; - fn latest_attempt_id_for_operation( - &self, - operation: MycOperationAuditKind, - ) -> Result<Option<String>, MycError>; -} - -#[derive(Debug, Clone)] -pub struct MycJsonlOperationAuditStore { - audit_dir: PathBuf, - config: MycAuditConfig, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct MycAuditRotationResult { - pruned_retained_records: bool, -} - -impl MycOperationAuditRecord { - pub fn new( - operation: MycOperationAuditKind, - outcome: MycOperationAuditOutcome, - connection_id: Option<&RadrootsNostrSignerConnectionId>, - request_id: Option<&str>, - relay_count: usize, - acknowledged_relay_count: usize, - relay_outcome_summary: impl Into<String>, - ) -> Self { - Self { - recorded_at_unix: now_unix_secs(), - operation, - outcome, - relay_url: None, - connection_id: connection_id.map(ToString::to_string), - request_id: request_id.map(ToOwned::to_owned), - attempt_id: None, - planned_repair_relays: Vec::new(), - blocked_relays: Vec::new(), - blocked_reason: None, - delivery_policy: None, - required_acknowledged_relay_count: None, - publish_attempt_count: None, - relay_count, - acknowledged_relay_count, - relay_outcome_summary: relay_outcome_summary.into(), - } - } - - pub fn with_relay_url(mut self, relay_url: impl Into<String>) -> Self { - self.relay_url = Some(relay_url.into()); - self - } - - pub fn with_attempt_id(mut self, attempt_id: impl Into<String>) -> Self { - self.attempt_id = Some(attempt_id.into()); - self - } - - pub fn with_planned_repair_relays(mut self, planned_repair_relays: Vec<String>) -> Self { - self.planned_repair_relays = planned_repair_relays; - self - } - - pub fn with_blocked_relays( - mut self, - blocked_reason: impl Into<String>, - blocked_relays: Vec<String>, - ) -> Self { - self.blocked_reason = Some(blocked_reason.into()); - self.blocked_relays = blocked_relays; - self - } - - pub fn with_delivery_details( - mut self, - delivery_policy: MycTransportDeliveryPolicy, - required_acknowledged_relay_count: usize, - publish_attempt_count: usize, - ) -> Self { - self.delivery_policy = Some(delivery_policy); - self.required_acknowledged_relay_count = Some(required_acknowledged_relay_count); - self.publish_attempt_count = Some(publish_attempt_count); - self - } -} - -impl MycJsonlOperationAuditStore { - pub fn new(audit_dir: impl AsRef<Path>, config: MycAuditConfig) -> Self { - Self { - audit_dir: audit_dir.as_ref().to_path_buf(), - config, - } - } - - pub fn path(&self) -> PathBuf { - self.active_path() - } - - pub fn config(&self) -> &MycAuditConfig { - &self.config - } - - pub fn append(&self, record: &MycOperationAuditRecord) -> Result<(), MycError> { - let active_path = self.active_path(); - let encoded = serde_json::to_vec(record).map_err(|source| MycError::AuditSerialize { - path: active_path.clone(), - source, - })?; - let rotation = self.rotate_if_needed(encoded.len() as u64 + 1)?; - self.append_encoded_record_line(&active_path, &encoded)?; - - if rotation.pruned_retained_records { - self.rebuild_query_indexes_from_retained_logs()?; - } else { - self.append_record_to_indexes(record)?; - } - Ok(()) - } - - pub fn list(&self) -> Result<Vec<MycOperationAuditRecord>, MycError> { - self.list_with_limit(self.config.default_read_limit) - } - - pub fn list_all(&self) -> Result<Vec<MycOperationAuditRecord>, MycError> { - self.list_matching(usize::MAX, |_| true) - } - - pub fn list_with_limit(&self, limit: usize) -> Result<Vec<MycOperationAuditRecord>, MycError> { - self.list_matching(limit, |_| true) - } - - pub fn list_for_connection( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<Vec<MycOperationAuditRecord>, MycError> { - self.list_for_connection_with_limit(connection_id, self.config.default_read_limit) - } - - pub fn list_for_connection_with_limit( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - limit: usize, - ) -> Result<Vec<MycOperationAuditRecord>, MycError> { - self.list_matching(limit, |record| { - record.connection_id.as_deref() == Some(connection_id.as_str()) - }) - } - - pub fn list_for_attempt_id( - &self, - attempt_id: &str, - ) -> Result<Vec<MycOperationAuditRecord>, MycError> { - self.list_for_attempt_id_with_limit(attempt_id, usize::MAX) - } - - pub fn list_for_attempt_id_with_limit( - &self, - attempt_id: &str, - limit: usize, - ) -> Result<Vec<MycOperationAuditRecord>, MycError> { - if limit == 0 { - return Ok(Vec::new()); - } - - let attempt_path = self.attempt_index_path(attempt_id); - if !attempt_path.exists() { - self.rebuild_query_indexes_from_retained_logs()?; - } - self.read_recent_records_from_path_with_limit(&attempt_path, limit) - } - - pub fn latest_attempt_id_for_operation( - &self, - operation: MycOperationAuditKind, - ) -> Result<Option<String>, MycError> { - let latest_path = self.latest_attempt_path(operation); - if !latest_path.exists() { - self.rebuild_query_indexes_from_retained_logs()?; - } - self.read_latest_attempt_id_from_path(&latest_path) - } - - fn list_matching<F>( - &self, - limit: usize, - predicate: F, - ) -> Result<Vec<MycOperationAuditRecord>, MycError> - where - F: Fn(&MycOperationAuditRecord) -> bool, - { - if limit == 0 { - return Ok(Vec::new()); - } - - let mut newest_records = Vec::new(); - for path in self.read_paths_newest_first()? { - let remaining = limit.saturating_sub(newest_records.len()); - if remaining == 0 { - break; - } - - let mut file_records = - self.read_recent_records_from_path_matching(&path, remaining, &predicate)?; - file_records.reverse(); - newest_records.extend(file_records); - } - - newest_records.reverse(); - Ok(newest_records) - } - - fn read_records_from_path( - &self, - path: &Path, - ) -> Result<Vec<MycOperationAuditRecord>, MycError> { - if !path.exists() { - return Ok(Vec::new()); - } - - let file = fs::File::open(path).map_err(|source| MycError::AuditIo { - path: path.to_path_buf(), - source, - })?; - let reader = BufReader::new(file); - let mut records = Vec::new(); - - for (line_number, line) in reader.lines().enumerate() { - let line = line.map_err(|source| MycError::AuditIo { - path: path.to_path_buf(), - source, - })?; - if line.trim().is_empty() { - continue; - } - - let record = - serde_json::from_str::<MycOperationAuditRecord>(&line).map_err(|source| { - MycError::AuditParse { - path: path.to_path_buf(), - line_number: line_number + 1, - source, - } - })?; - records.push(record); - } - - Ok(records) - } - - fn rotate_if_needed(&self, additional_bytes: u64) -> Result<MycAuditRotationResult, MycError> { - let active_path = self.active_path(); - let current_len = match fs::metadata(&active_path) { - Ok(metadata) => metadata.len(), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => 0, - Err(source) => { - return Err(MycError::AuditIo { - path: active_path, - source, - }); - } - }; - - if current_len == 0 - || current_len.saturating_add(additional_bytes) <= self.config.max_active_file_bytes - { - return Ok(MycAuditRotationResult { - pruned_retained_records: false, - }); - } - - self.rotate_active_file() - } - - fn rotate_active_file(&self) -> Result<MycAuditRotationResult, MycError> { - let mut pruned_retained_records = false; - for index in (1..=self.config.max_archived_files).rev() { - let archived_path = self.archive_path(index); - if !archived_path.exists() { - continue; - } - - if index == self.config.max_archived_files { - fs::remove_file(&archived_path).map_err(|source| MycError::AuditIo { - path: archived_path, - source, - })?; - pruned_retained_records = true; - } else { - let next_path = self.archive_path(index + 1); - fs::rename(&archived_path, &next_path).map_err(|source| MycError::AuditIo { - path: archived_path, - source, - })?; - } - } - - let active_path = self.active_path(); - if !active_path.exists() { - return Ok(MycAuditRotationResult { - pruned_retained_records, - }); - } - - if self.config.max_archived_files == 0 { - fs::remove_file(&active_path).map_err(|source| MycError::AuditIo { - path: active_path, - source, - })?; - return Ok(MycAuditRotationResult { - pruned_retained_records: true, - }); - } - - let first_archive = self.archive_path(1); - fs::rename(&active_path, &first_archive).map_err(|source| MycError::AuditIo { - path: active_path, - source, - })?; - Ok(MycAuditRotationResult { - pruned_retained_records, - }) - } - - fn read_paths_newest_first(&self) -> Result<Vec<PathBuf>, MycError> { - let mut paths = Vec::new(); - let active_path = self.active_path(); - if active_path.exists() { - paths.push(active_path); - } - - let mut archived = self.archived_paths()?; - archived.sort_by_key(|(_, index)| *index); - for (path, _) in archived { - paths.push(path); - } - - Ok(paths) - } - - fn archived_paths(&self) -> Result<Vec<(PathBuf, usize)>, MycError> { - let mut archived = Vec::new(); - if !self.audit_dir.exists() { - return Ok(archived); - } - - for entry in fs::read_dir(&self.audit_dir).map_err(|source| MycError::AuditIo { - path: self.audit_dir.clone(), - source, - })? { - let entry = entry.map_err(|source| MycError::AuditIo { - path: self.audit_dir.clone(), - source, - })?; - let file_name = entry.file_name(); - let Some(file_name) = file_name.to_str() else { - continue; - }; - let Some(index) = parse_archive_index(file_name) else { - continue; - }; - archived.push((entry.path(), index)); - } - - Ok(archived) - } - - fn active_path(&self) -> PathBuf { - self.audit_dir.join(MYC_OPERATION_AUDIT_FILE_NAME) - } - - fn archive_path(&self, index: usize) -> PathBuf { - self.audit_dir.join(format!( - "{MYC_OPERATION_AUDIT_ARCHIVE_PREFIX}{index}{MYC_OPERATION_AUDIT_ARCHIVE_SUFFIX}" - )) - } - - fn index_dir(&self) -> PathBuf { - self.audit_dir.join(MYC_OPERATION_AUDIT_INDEX_DIR_NAME) - } - - fn attempt_index_dir(&self) -> PathBuf { - self.index_dir().join(MYC_OPERATION_AUDIT_ATTEMPTS_DIR_NAME) - } - - fn latest_attempt_dir(&self) -> PathBuf { - self.index_dir().join(MYC_OPERATION_AUDIT_LATEST_DIR_NAME) - } - - fn attempt_index_path(&self, attempt_id: &str) -> PathBuf { - self.attempt_index_dir() - .join(format!("{}.jsonl", encode_index_component(attempt_id))) - } - - fn latest_attempt_path(&self, operation: MycOperationAuditKind) -> PathBuf { - self.latest_attempt_dir().join(format!( - "{}{MYC_OPERATION_AUDIT_LATEST_SUFFIX}", - operation_index_label(operation) - )) - } - - fn append_encoded_record_line(&self, path: &Path, encoded: &[u8]) -> Result<(), MycError> { - let mut file = OpenOptions::new() - .create(true) - .append(true) - .open(path) - .map_err(|source| MycError::AuditIo { - path: path.to_path_buf(), - source, - })?; - file.write_all(encoded) - .map_err(|source| MycError::AuditIo { - path: path.to_path_buf(), - source, - })?; - file.write_all(b"\n").map_err(|source| MycError::AuditIo { - path: path.to_path_buf(), - source, - })?; - Ok(()) - } - - fn append_record_to_indexes(&self, record: &MycOperationAuditRecord) -> Result<(), MycError> { - let Some(attempt_id) = record.attempt_id.as_deref() else { - return Ok(()); - }; - - self.ensure_index_dirs()?; - self.append_record_to_index_root(&self.index_dir(), record)?; - self.write_latest_attempt_pointer(record.operation, attempt_id) - } - - fn ensure_index_dirs(&self) -> Result<(), MycError> { - fs::create_dir_all(self.attempt_index_dir()).map_err(|source| MycError::AuditIo { - path: self.attempt_index_dir(), - source, - })?; - fs::create_dir_all(self.latest_attempt_dir()).map_err(|source| MycError::AuditIo { - path: self.latest_attempt_dir(), - source, - })?; - Ok(()) - } - - fn append_record_to_index_root( - &self, - index_root: &Path, - record: &MycOperationAuditRecord, - ) -> Result<(), MycError> { - let Some(attempt_id) = record.attempt_id.as_deref() else { - return Ok(()); - }; - - let attempts_dir = index_root.join(MYC_OPERATION_AUDIT_ATTEMPTS_DIR_NAME); - fs::create_dir_all(&attempts_dir).map_err(|source| MycError::AuditIo { - path: attempts_dir.clone(), - source, - })?; - let latest_dir = index_root.join(MYC_OPERATION_AUDIT_LATEST_DIR_NAME); - fs::create_dir_all(&latest_dir).map_err(|source| MycError::AuditIo { - path: latest_dir.clone(), - source, - })?; - - let encoded = serde_json::to_vec(record).map_err(|source| MycError::AuditSerialize { - path: attempts_dir.join(format!("{}.jsonl", encode_index_component(attempt_id))), - source, - })?; - self.append_encoded_record_line( - &attempts_dir.join(format!("{}.jsonl", encode_index_component(attempt_id))), - &encoded, - )?; - self.write_latest_attempt_pointer_to_root(index_root, record.operation, attempt_id) - } - - fn write_latest_attempt_pointer( - &self, - operation: MycOperationAuditKind, - attempt_id: &str, - ) -> Result<(), MycError> { - self.write_latest_attempt_pointer_to_root(&self.index_dir(), operation, attempt_id) - } - - fn write_latest_attempt_pointer_to_root( - &self, - index_root: &Path, - operation: MycOperationAuditKind, - attempt_id: &str, - ) -> Result<(), MycError> { - let latest_dir = index_root.join(MYC_OPERATION_AUDIT_LATEST_DIR_NAME); - fs::create_dir_all(&latest_dir).map_err(|source| MycError::AuditIo { - path: latest_dir.clone(), - source, - })?; - let path = latest_dir.join(format!( - "{}{MYC_OPERATION_AUDIT_LATEST_SUFFIX}", - operation_index_label(operation) - )); - write_atomic_text(&path, attempt_id) - } - - fn rebuild_query_indexes_from_retained_logs(&self) -> Result<(), MycError> { - let staging_root = self.audit_dir.join(MYC_OPERATION_AUDIT_INDEX_TMP_DIR_NAME); - if staging_root.exists() { - fs::remove_dir_all(&staging_root).map_err(|source| MycError::AuditIo { - path: staging_root.clone(), - source, - })?; - } - fs::create_dir_all(&staging_root).map_err(|source| MycError::AuditIo { - path: staging_root.clone(), - source, - })?; - - let mut retained_paths = self.read_paths_newest_first()?; - retained_paths.reverse(); - for path in retained_paths { - for record in self.read_records_from_path(&path)? { - self.append_record_to_index_root(&staging_root, &record)?; - } - } - - let final_root = self.index_dir(); - if final_root.exists() { - fs::remove_dir_all(&final_root).map_err(|source| MycError::AuditIo { - path: final_root.clone(), - source, - })?; - } - fs::rename(&staging_root, &final_root).map_err(|source| MycError::AuditIo { - path: staging_root, - source, - })?; - Ok(()) - } - - fn read_latest_attempt_id_from_path(&self, path: &Path) -> Result<Option<String>, MycError> { - match fs::read_to_string(path) { - Ok(contents) => { - let attempt_id = contents.trim(); - if attempt_id.is_empty() { - Ok(None) - } else { - Ok(Some(attempt_id.to_owned())) - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(source) => Err(MycError::AuditIo { - path: path.to_path_buf(), - source, - }), - } - } - - fn read_recent_records_from_path_with_limit( - &self, - path: &Path, - limit: usize, - ) -> Result<Vec<MycOperationAuditRecord>, MycError> { - self.read_recent_records_from_path_matching(path, limit, &|_| true) - } - - fn read_recent_records_from_path_matching<F>( - &self, - path: &Path, - limit: usize, - predicate: &F, - ) -> Result<Vec<MycOperationAuditRecord>, MycError> - where - F: Fn(&MycOperationAuditRecord) -> bool, - { - if limit == 0 || !path.exists() { - return Ok(Vec::new()); - } - - let file = fs::File::open(path).map_err(|source| MycError::AuditIo { - path: path.to_path_buf(), - source, - })?; - let reader = BufReader::new(file); - let mut recent_records = VecDeque::new(); - - for (line_number, line) in reader.lines().enumerate() { - let line = line.map_err(|source| MycError::AuditIo { - path: path.to_path_buf(), - source, - })?; - if line.trim().is_empty() { - continue; - } - - let record = - serde_json::from_str::<MycOperationAuditRecord>(&line).map_err(|source| { - MycError::AuditParse { - path: path.to_path_buf(), - line_number: line_number + 1, - source, - } - })?; - if !predicate(&record) { - continue; - } - - if recent_records.len() == limit { - recent_records.pop_front(); - } - recent_records.push_back(record); - } - - Ok(recent_records.into_iter().collect()) - } -} - -impl MycOperationAuditStore for MycJsonlOperationAuditStore { - fn config(&self) -> &MycAuditConfig { - &self.config - } - - fn append(&self, record: &MycOperationAuditRecord) -> Result<(), MycError> { - MycJsonlOperationAuditStore::append(self, record) - } - - fn list_all(&self) -> Result<Vec<MycOperationAuditRecord>, MycError> { - MycJsonlOperationAuditStore::list_all(self) - } - - fn list_with_limit(&self, limit: usize) -> Result<Vec<MycOperationAuditRecord>, MycError> { - MycJsonlOperationAuditStore::list_with_limit(self, limit) - } - - fn list_for_connection_with_limit( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - limit: usize, - ) -> Result<Vec<MycOperationAuditRecord>, MycError> { - MycJsonlOperationAuditStore::list_for_connection_with_limit(self, connection_id, limit) - } - - fn list_for_attempt_id_with_limit( - &self, - attempt_id: &str, - limit: usize, - ) -> Result<Vec<MycOperationAuditRecord>, MycError> { - MycJsonlOperationAuditStore::list_for_attempt_id_with_limit(self, attempt_id, limit) - } - - fn latest_attempt_id_for_operation( - &self, - operation: MycOperationAuditKind, - ) -> Result<Option<String>, MycError> { - MycJsonlOperationAuditStore::latest_attempt_id_for_operation(self, operation) - } -} - -fn parse_archive_index(file_name: &str) -> Option<usize> { - file_name - .strip_prefix(MYC_OPERATION_AUDIT_ARCHIVE_PREFIX)? - .strip_suffix(MYC_OPERATION_AUDIT_ARCHIVE_SUFFIX)? - .parse() - .ok() -} - -fn operation_index_label(kind: MycOperationAuditKind) -> &'static str { - match kind { - MycOperationAuditKind::DeliveryRecovery => "delivery_recovery", - MycOperationAuditKind::ListenerResponsePublish => "listener_response_publish", - MycOperationAuditKind::ConnectAcceptPublish => "connect_accept_publish", - MycOperationAuditKind::AuthReplayPublish => "auth_replay_publish", - MycOperationAuditKind::AuthReplayRestore => "auth_replay_restore", - MycOperationAuditKind::DiscoveryHandlerFetch => "discovery_handler_fetch", - MycOperationAuditKind::DiscoveryHandlerPublish => "discovery_handler_publish", - MycOperationAuditKind::DiscoveryHandlerCompare => "discovery_handler_compare", - MycOperationAuditKind::DiscoveryHandlerRefresh => "discovery_handler_refresh", - MycOperationAuditKind::DiscoveryHandlerRepair => "discovery_handler_repair", - } -} - -fn encode_index_component(value: &str) -> String { - let mut encoded = String::with_capacity(value.len() * 2); - for byte in value.bytes() { - encoded.push_str(&format!("{byte:02x}")); - } - encoded -} - -fn write_atomic_text(path: &Path, contents: &str) -> Result<(), MycError> { - let tmp_path = path.with_extension("tmp"); - fs::write(&tmp_path, contents).map_err(|source| MycError::AuditIo { - path: tmp_path.clone(), - source, - })?; - fs::rename(&tmp_path, path).map_err(|source| MycError::AuditIo { - path: tmp_path, - source, - })?; - Ok(()) -} - -fn now_unix_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock is before unix epoch") - .as_secs() -} - -#[cfg(test)] -mod tests { - use std::fs; - - use crate::signer::prelude::RadrootsNostrSignerConnectionId; - - use crate::config::MycAuditConfig; - - use super::{ - MycJsonlOperationAuditStore, MycOperationAuditKind, MycOperationAuditOutcome, - MycOperationAuditRecord, - }; - - fn config() -> MycAuditConfig { - MycAuditConfig { - default_read_limit: 10, - max_active_file_bytes: 512, - max_archived_files: 2, - } - } - - #[test] - fn append_and_list_operation_audit_records() { - let temp = tempfile::tempdir().expect("tempdir"); - let store = MycJsonlOperationAuditStore::new(temp.path(), config()); - let connection_id = - RadrootsNostrSignerConnectionId::parse("connection-1").expect("connection id"); - - store - .append( - &MycOperationAuditRecord::new( - MycOperationAuditKind::ConnectAcceptPublish, - MycOperationAuditOutcome::Rejected, - Some(&connection_id), - Some("request-1"), - 2, - 0, - "0/2 relays acknowledged publish; failures: relay-a: rejected", - ) - .with_attempt_id("attempt-1"), - ) - .expect("append rejected record"); - store - .append(&MycOperationAuditRecord::new( - MycOperationAuditKind::AuthReplayRestore, - MycOperationAuditOutcome::Restored, - Some(&connection_id), - Some("request-1"), - 0, - 0, - "restored pending auth challenge after replay publish rejection", - )) - .expect("append restored record"); - - let records = store.list().expect("list records"); - assert_eq!(records.len(), 2); - assert_eq!( - records[0].operation, - MycOperationAuditKind::ConnectAcceptPublish - ); - assert_eq!(records[0].outcome, MycOperationAuditOutcome::Rejected); - assert_eq!(records[0].connection_id.as_deref(), Some("connection-1")); - assert_eq!(records[0].request_id.as_deref(), Some("request-1")); - assert_eq!(records[0].attempt_id.as_deref(), Some("attempt-1")); - assert_eq!(records[0].relay_count, 2); - assert_eq!(records[0].acknowledged_relay_count, 0); - - let connection_records = store - .list_for_connection(&connection_id) - .expect("list connection records"); - assert_eq!(connection_records, records); - } - - #[test] - fn list_returns_empty_when_audit_file_is_missing() { - let temp = tempfile::tempdir().expect("tempdir"); - let store = MycJsonlOperationAuditStore::new(temp.path(), config()); - - assert!(store.list().expect("list missing records").is_empty()); - } - - #[test] - fn rotation_and_bounded_reads_keep_recent_records() { - let temp = tempfile::tempdir().expect("tempdir"); - let store = MycJsonlOperationAuditStore::new( - temp.path(), - MycAuditConfig { - default_read_limit: 3, - max_active_file_bytes: 180, - max_archived_files: 2, - }, - ); - - for index in 0..6 { - store - .append( - &MycOperationAuditRecord::new( - MycOperationAuditKind::ListenerResponsePublish, - MycOperationAuditOutcome::Rejected, - None, - Some(&format!("request-{index}")), - 1, - 0, - format!("failure-{index}"), - ) - .with_attempt_id(format!("attempt-{index}")), - ) - .expect("append record"); - } - - let records = store.list().expect("list bounded records"); - assert_eq!(records.len(), 3); - assert_eq!(records[0].request_id.as_deref(), Some("request-3")); - assert_eq!(records[2].request_id.as_deref(), Some("request-5")); - assert!(temp.path().join("operations.1.jsonl").exists()); - assert!(temp.path().join("operations.2.jsonl").exists()); - assert!(!temp.path().join("operations.3.jsonl").exists()); - } - - #[test] - fn list_for_attempt_and_latest_attempt_id_work() { - let temp = tempfile::tempdir().expect("tempdir"); - let store = MycJsonlOperationAuditStore::new(temp.path(), config()); - - store - .append( - &MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerRefresh, - MycOperationAuditOutcome::Rejected, - None, - None, - 2, - 0, - "first attempt rejected", - ) - .with_attempt_id("attempt-1") - .with_planned_repair_relays(vec!["wss://relay-a.example.com".to_owned()]) - .with_blocked_relays( - "unavailable_relays", - vec!["wss://relay-b.example.com".to_owned()], - ), - ) - .expect("append first attempt"); - store - .append( - &MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerRepair, - MycOperationAuditOutcome::Rejected, - None, - None, - 1, - 0, - "relay-a rejected", - ) - .with_attempt_id("attempt-1") - .with_relay_url("wss://relay-a.example.com"), - ) - .expect("append first repair"); - store - .append( - &MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerRefresh, - MycOperationAuditOutcome::Succeeded, - None, - None, - 1, - 1, - "second attempt succeeded", - ) - .with_attempt_id("attempt-2"), - ) - .expect("append second attempt"); - - let attempt_records = store - .list_for_attempt_id("attempt-1") - .expect("list attempt records"); - assert_eq!(attempt_records.len(), 2); - assert!( - attempt_records - .iter() - .all(|record| record.attempt_id.as_deref() == Some("attempt-1")) - ); - assert_eq!( - attempt_records[0].planned_repair_relays, - vec!["wss://relay-a.example.com".to_owned()] - ); - assert_eq!( - attempt_records[0].blocked_relays, - vec!["wss://relay-b.example.com".to_owned()] - ); - assert_eq!( - attempt_records[0].blocked_reason.as_deref(), - Some("unavailable_relays") - ); - assert_eq!( - store - .latest_attempt_id_for_operation(MycOperationAuditKind::DiscoveryHandlerRefresh) - .expect("latest attempt"), - Some("attempt-2".to_owned()) - ); - } - - #[test] - fn attempt_lookup_rebuilds_indexes_from_retained_logs() { - let temp = tempfile::tempdir().expect("tempdir"); - let store = MycJsonlOperationAuditStore::new(temp.path(), config()); - - store - .append( - &MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerRefresh, - MycOperationAuditOutcome::Rejected, - None, - None, - 2, - 0, - "first attempt rejected", - ) - .with_attempt_id("attempt-1"), - ) - .expect("append first attempt"); - store - .append( - &MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerRefresh, - MycOperationAuditOutcome::Succeeded, - None, - None, - 1, - 1, - "second attempt succeeded", - ) - .with_attempt_id("attempt-2"), - ) - .expect("append second attempt"); - - fs::remove_dir_all(store.index_dir()).expect("remove index dir"); - - let rebuilt_attempt_records = store - .list_for_attempt_id("attempt-1") - .expect("rebuild attempt records"); - assert_eq!(rebuilt_attempt_records.len(), 1); - assert_eq!( - rebuilt_attempt_records[0].attempt_id.as_deref(), - Some("attempt-1") - ); - assert_eq!( - store - .latest_attempt_id_for_operation(MycOperationAuditKind::DiscoveryHandlerRefresh) - .expect("latest attempt after rebuild"), - Some("attempt-2".to_owned()) - ); - assert!(store.attempt_index_path("attempt-1").exists()); - assert!( - store - .latest_attempt_path(MycOperationAuditKind::DiscoveryHandlerRefresh) - .exists() - ); - } -} diff --git a/src/audit_sqlite.rs b/src/audit_sqlite.rs @@ -1,786 +0,0 @@ -use std::path::{Path, PathBuf}; - -use crate::signer::prelude::RadrootsNostrSignerConnectionId; -use crate::sql::migrations::{Migration, migrations_run_all_up}; -use crate::sql::{SqlExecutor, SqlxSqliteExecutor}; -use serde::Deserialize; -use serde::de::DeserializeOwned; -use serde_json::{Value, json}; - -use crate::audit::{ - MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord, - MycOperationAuditStore, -}; -use crate::config::{MycAuditConfig, MycTransportDeliveryPolicy}; -use crate::error::MycError; - -const MYC_OPERATION_AUDIT_SQLITE_FILE_NAME: &str = "operations.sqlite"; -#[cfg(test)] -const MYC_OPERATION_AUDIT_MEMORY_PATH: &str = ":memory:"; - -static MYC_OPERATION_AUDIT_MIGRATIONS: &[Migration] = &[Migration { - name: "0000_runtime_audit_init", - up_sql: include_str!("../migrations/0000_runtime_audit_init.up.sql"), - down_sql: include_str!("../migrations/0000_runtime_audit_init.down.sql"), -}]; - -/// Myc keeps its operational audit store local to the service boundary. -pub struct MycSqliteOperationAuditStore { - db: MycOperationAuditSqliteDb, - config: MycAuditConfig, -} - -struct MycOperationAuditSqliteDb { - path: PathBuf, - executor: SqlxSqliteExecutor, - file_backed: bool, -} - -#[derive(Debug, Deserialize)] -struct MycOperationAuditRow { - audit_record_id: i64, - recorded_at_unix: u64, - operation: String, - outcome: String, - relay_url: Option<String>, - connection_id: Option<String>, - request_id: Option<String>, - attempt_id: Option<String>, - planned_repair_relays_json: String, - blocked_relays_json: String, - blocked_reason: Option<String>, - delivery_policy: Option<String>, - required_acknowledged_relay_count: Option<i64>, - publish_attempt_count: Option<i64>, - relay_count: i64, - acknowledged_relay_count: i64, - relay_outcome_summary: String, -} - -#[derive(Debug, Deserialize)] -struct MycLatestAttemptRow { - attempt_id: String, -} - -impl MycSqliteOperationAuditStore { - pub fn open(audit_dir: impl AsRef<Path>, config: MycAuditConfig) -> Result<Self, MycError> { - let db = MycOperationAuditSqliteDb::open( - audit_dir - .as_ref() - .join(MYC_OPERATION_AUDIT_SQLITE_FILE_NAME), - )?; - Ok(Self { db, config }) - } - - #[cfg(test)] - pub fn open_memory(config: MycAuditConfig) -> Result<Self, MycError> { - let db = MycOperationAuditSqliteDb::open_memory()?; - Ok(Self { db, config }) - } - - pub fn path(&self) -> &Path { - self.db.path() - } - - pub fn config(&self) -> &MycAuditConfig { - &self.config - } - - pub fn append(&self, record: &MycOperationAuditRecord) -> Result<(), MycError> { - let planned_repair_relays_json = - serialize_json_field(self.db.path(), &record.planned_repair_relays)?; - let blocked_relays_json = serialize_json_field(self.db.path(), &record.blocked_relays)?; - exec_json( - self.db.path(), - self.db.executor(), - "INSERT INTO myc_operation_audit(recorded_at_unix, operation, outcome, relay_url, connection_id, request_id, attempt_id, planned_repair_relays_json, blocked_relays_json, blocked_reason, delivery_policy, required_acknowledged_relay_count, publish_attempt_count, relay_count, acknowledged_relay_count, relay_outcome_summary) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - json!([ - record.recorded_at_unix, - operation_kind_label(record.operation), - operation_outcome_label(record.outcome), - record.relay_url.clone(), - record.connection_id.clone(), - record.request_id.clone(), - record.attempt_id.clone(), - planned_repair_relays_json, - blocked_relays_json, - record.blocked_reason.clone(), - record - .delivery_policy - .map(MycTransportDeliveryPolicy::as_str), - record.required_acknowledged_relay_count, - record.publish_attempt_count, - record.relay_count, - record.acknowledged_relay_count, - record.relay_outcome_summary.clone(), - ]), - ) - } - - pub fn list_all(&self) -> Result<Vec<MycOperationAuditRecord>, MycError> { - self.query_records( - "SELECT audit_record_id, recorded_at_unix, operation, outcome, relay_url, connection_id, request_id, attempt_id, planned_repair_relays_json, blocked_relays_json, blocked_reason, delivery_policy, required_acknowledged_relay_count, publish_attempt_count, relay_count, acknowledged_relay_count, relay_outcome_summary FROM myc_operation_audit ORDER BY recorded_at_unix ASC, audit_record_id ASC", - json!([]), - ) - } - - pub fn list_with_limit(&self, limit: usize) -> Result<Vec<MycOperationAuditRecord>, MycError> { - if limit == 0 { - return Ok(Vec::new()); - } - - let mut records = self.query_records_with_limit( - "SELECT audit_record_id, recorded_at_unix, operation, outcome, relay_url, connection_id, request_id, attempt_id, planned_repair_relays_json, blocked_relays_json, blocked_reason, delivery_policy, required_acknowledged_relay_count, publish_attempt_count, relay_count, acknowledged_relay_count, relay_outcome_summary FROM myc_operation_audit ORDER BY recorded_at_unix DESC, audit_record_id DESC", - json!([]), - limit, - )?; - records.reverse(); - Ok(records) - } - - pub fn list_for_connection_with_limit( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - limit: usize, - ) -> Result<Vec<MycOperationAuditRecord>, MycError> { - if limit == 0 { - return Ok(Vec::new()); - } - - let mut records = self.query_records_with_limit( - "SELECT audit_record_id, recorded_at_unix, operation, outcome, relay_url, connection_id, request_id, attempt_id, planned_repair_relays_json, blocked_relays_json, blocked_reason, delivery_policy, required_acknowledged_relay_count, publish_attempt_count, relay_count, acknowledged_relay_count, relay_outcome_summary FROM myc_operation_audit WHERE connection_id = ? ORDER BY recorded_at_unix DESC, audit_record_id DESC", - json!([connection_id.as_str()]), - limit, - )?; - records.reverse(); - Ok(records) - } - - pub fn list_for_attempt_id_with_limit( - &self, - attempt_id: &str, - limit: usize, - ) -> Result<Vec<MycOperationAuditRecord>, MycError> { - if limit == 0 { - return Ok(Vec::new()); - } - - let mut records = self.query_records_with_limit( - "SELECT audit_record_id, recorded_at_unix, operation, outcome, relay_url, connection_id, request_id, attempt_id, planned_repair_relays_json, blocked_relays_json, blocked_reason, delivery_policy, required_acknowledged_relay_count, publish_attempt_count, relay_count, acknowledged_relay_count, relay_outcome_summary FROM myc_operation_audit WHERE attempt_id = ? ORDER BY recorded_at_unix DESC, audit_record_id DESC", - json!([attempt_id]), - limit, - )?; - records.reverse(); - Ok(records) - } - - pub fn latest_attempt_id_for_operation( - &self, - operation: MycOperationAuditKind, - ) -> Result<Option<String>, MycError> { - let rows: Vec<MycLatestAttemptRow> = query_rows( - self.db.path(), - self.db.executor(), - "SELECT attempt_id FROM myc_operation_audit WHERE operation = ? AND attempt_id IS NOT NULL ORDER BY recorded_at_unix DESC, audit_record_id DESC LIMIT 1", - json!([operation_kind_label(operation)]), - )?; - Ok(rows.into_iter().next().map(|row| row.attempt_id)) - } - - fn query_records( - &self, - sql: &str, - params: Value, - ) -> Result<Vec<MycOperationAuditRecord>, MycError> { - let rows: Vec<MycOperationAuditRow> = - query_rows(self.db.path(), self.db.executor(), sql, params)?; - rows.into_iter() - .map(|row| row.into_record(self.db.path())) - .collect() - } - - fn query_records_with_limit( - &self, - base_sql: &str, - params: Value, - limit: usize, - ) -> Result<Vec<MycOperationAuditRecord>, MycError> { - if limit == usize::MAX { - return self.query_records(base_sql, params); - } - - let limit = i64::try_from(limit).map_err(|_| { - MycError::InvalidOperation("audit read limit exceeds sqlite range".to_owned()) - })?; - let mut params = params.as_array().cloned().unwrap_or_default(); - params.push(Value::from(limit)); - let sql = format!("{base_sql} LIMIT ?"); - self.query_records(sql.as_str(), Value::Array(params)) - } -} - -impl MycOperationAuditStore for MycSqliteOperationAuditStore { - fn config(&self) -> &MycAuditConfig { - &self.config - } - - fn append(&self, record: &MycOperationAuditRecord) -> Result<(), MycError> { - MycSqliteOperationAuditStore::append(self, record) - } - - fn list_all(&self) -> Result<Vec<MycOperationAuditRecord>, MycError> { - MycSqliteOperationAuditStore::list_all(self) - } - - fn list_with_limit(&self, limit: usize) -> Result<Vec<MycOperationAuditRecord>, MycError> { - MycSqliteOperationAuditStore::list_with_limit(self, limit) - } - - fn list_for_connection_with_limit( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - limit: usize, - ) -> Result<Vec<MycOperationAuditRecord>, MycError> { - MycSqliteOperationAuditStore::list_for_connection_with_limit(self, connection_id, limit) - } - - fn list_for_attempt_id_with_limit( - &self, - attempt_id: &str, - limit: usize, - ) -> Result<Vec<MycOperationAuditRecord>, MycError> { - MycSqliteOperationAuditStore::list_for_attempt_id_with_limit(self, attempt_id, limit) - } - - fn latest_attempt_id_for_operation( - &self, - operation: MycOperationAuditKind, - ) -> Result<Option<String>, MycError> { - MycSqliteOperationAuditStore::latest_attempt_id_for_operation(self, operation) - } -} - -impl MycOperationAuditSqliteDb { - fn open(path: impl AsRef<Path>) -> Result<Self, MycError> { - let path = path.as_ref().to_path_buf(); - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() - { - std::fs::create_dir_all(parent).map_err(|source| MycError::CreateDir { - path: parent.to_path_buf(), - source, - })?; - } - let executor = SqlxSqliteExecutor::open(&path).map_err(|source| MycError::AuditSql { - path: path.clone(), - source, - })?; - let db = Self { - path, - executor, - file_backed: true, - }; - db.configure()?; - db.migrate_up()?; - Ok(db) - } - - #[cfg(test)] - fn open_memory() -> Result<Self, MycError> { - let path = PathBuf::from(MYC_OPERATION_AUDIT_MEMORY_PATH); - let executor = SqlxSqliteExecutor::open_memory().map_err(|source| MycError::AuditSql { - path: path.clone(), - source, - })?; - let db = Self { - path, - executor, - file_backed: false, - }; - db.configure()?; - db.migrate_up()?; - Ok(db) - } - - fn path(&self) -> &Path { - &self.path - } - - fn executor(&self) -> &SqlxSqliteExecutor { - &self.executor - } - - fn migrate_up(&self) -> Result<(), MycError> { - migrations_run_all_up(&self.executor, MYC_OPERATION_AUDIT_MIGRATIONS).map_err(|source| { - MycError::AuditSql { - path: self.path.clone(), - source, - } - }) - } - - #[cfg(test)] - fn migrate_down(&self) -> Result<(), MycError> { - use crate::sql::migrations::migrations_run_all_down; - - migrations_run_all_down(&self.executor, MYC_OPERATION_AUDIT_MIGRATIONS).map_err(|source| { - MycError::AuditSql { - path: self.path.clone(), - source, - } - }) - } - - fn configure(&self) -> Result<(), MycError> { - let pragma_batch = if self.file_backed { - "PRAGMA foreign_keys = ON; - PRAGMA synchronous = FULL; - PRAGMA wal_autocheckpoint = 1000; - PRAGMA busy_timeout = 5000; - PRAGMA temp_store = MEMORY;" - } else { - "PRAGMA foreign_keys = ON; - PRAGMA synchronous = NORMAL; - PRAGMA busy_timeout = 5000; - PRAGMA temp_store = MEMORY;" - }; - let _ = self - .executor - .exec(pragma_batch, "[]") - .map_err(|source| MycError::AuditSql { - path: self.path.clone(), - source, - })?; - let journal_mode_sql = if self.file_backed { - "PRAGMA journal_mode = WAL" - } else { - "PRAGMA journal_mode = MEMORY" - }; - let _ = self - .executor - .query_raw(journal_mode_sql, "[]") - .map_err(|source| MycError::AuditSql { - path: self.path.clone(), - source, - })?; - Ok(()) - } -} - -impl MycOperationAuditRow { - fn into_record(self, path: &Path) -> Result<MycOperationAuditRecord, MycError> { - let _audit_record_id = self.audit_record_id; - Ok(MycOperationAuditRecord { - recorded_at_unix: self.recorded_at_unix, - operation: parse_operation_kind(self.operation.as_str())?, - outcome: parse_operation_outcome(self.outcome.as_str())?, - relay_url: self.relay_url, - connection_id: self.connection_id, - request_id: self.request_id, - attempt_id: self.attempt_id, - planned_repair_relays: parse_json_field( - path, - self.planned_repair_relays_json.as_str(), - )?, - blocked_relays: parse_json_field(path, self.blocked_relays_json.as_str())?, - blocked_reason: self.blocked_reason, - delivery_policy: self - .delivery_policy - .as_deref() - .map(parse_delivery_policy) - .transpose()?, - required_acknowledged_relay_count: self - .required_acknowledged_relay_count - .map(parse_optional_usize) - .transpose()?, - publish_attempt_count: self - .publish_attempt_count - .map(parse_optional_usize) - .transpose()?, - relay_count: parse_required_usize(self.relay_count, "relay_count")?, - acknowledged_relay_count: parse_required_usize( - self.acknowledged_relay_count, - "acknowledged_relay_count", - )?, - relay_outcome_summary: self.relay_outcome_summary, - }) - } -} - -fn query_rows<T: DeserializeOwned>( - path: &Path, - executor: &impl SqlExecutor, - sql: &str, - params: Value, -) -> Result<Vec<T>, MycError> { - let raw = executor - .query_raw(sql, params.to_string().as_str()) - .map_err(|source| MycError::AuditSql { - path: path.to_path_buf(), - source, - })?; - serde_json::from_str(&raw).map_err(|source| MycError::AuditSqlDecode { - path: path.to_path_buf(), - source, - }) -} - -fn exec_json( - path: &Path, - executor: &impl SqlExecutor, - sql: &str, - params: Value, -) -> Result<(), MycError> { - let _ = executor - .exec(sql, params.to_string().as_str()) - .map_err(|source| MycError::AuditSql { - path: path.to_path_buf(), - source, - })?; - Ok(()) -} - -fn parse_json_field<T: DeserializeOwned>(path: &Path, value: &str) -> Result<T, MycError> { - serde_json::from_str(value).map_err(|source| MycError::AuditSqlDecode { - path: path.to_path_buf(), - source, - }) -} - -fn serialize_json_field<T: serde::Serialize>(path: &Path, value: &T) -> Result<String, MycError> { - serde_json::to_string(value).map_err(|source| MycError::AuditSerialize { - path: path.to_path_buf(), - source, - }) -} - -fn parse_required_usize(value: i64, field: &str) -> Result<usize, MycError> { - usize::try_from(value).map_err(|_| { - MycError::InvalidOperation(format!( - "sqlite runtime audit field `{field}` is out of range for usize" - )) - }) -} - -fn parse_optional_usize(value: i64) -> Result<usize, MycError> { - usize::try_from(value).map_err(|_| { - MycError::InvalidOperation( - "sqlite runtime audit optional integer field is out of range for usize".to_owned(), - ) - }) -} - -fn operation_kind_label(value: MycOperationAuditKind) -> &'static str { - match value { - MycOperationAuditKind::DeliveryRecovery => "delivery_recovery", - MycOperationAuditKind::ListenerResponsePublish => "listener_response_publish", - MycOperationAuditKind::ConnectAcceptPublish => "connect_accept_publish", - MycOperationAuditKind::AuthReplayPublish => "auth_replay_publish", - MycOperationAuditKind::AuthReplayRestore => "auth_replay_restore", - MycOperationAuditKind::DiscoveryHandlerFetch => "discovery_handler_fetch", - MycOperationAuditKind::DiscoveryHandlerPublish => "discovery_handler_publish", - MycOperationAuditKind::DiscoveryHandlerCompare => "discovery_handler_compare", - MycOperationAuditKind::DiscoveryHandlerRefresh => "discovery_handler_refresh", - MycOperationAuditKind::DiscoveryHandlerRepair => "discovery_handler_repair", - } -} - -fn parse_operation_kind(value: &str) -> Result<MycOperationAuditKind, MycError> { - match value { - "delivery_recovery" => Ok(MycOperationAuditKind::DeliveryRecovery), - "listener_response_publish" => Ok(MycOperationAuditKind::ListenerResponsePublish), - "connect_accept_publish" => Ok(MycOperationAuditKind::ConnectAcceptPublish), - "auth_replay_publish" => Ok(MycOperationAuditKind::AuthReplayPublish), - "auth_replay_restore" => Ok(MycOperationAuditKind::AuthReplayRestore), - "discovery_handler_fetch" => Ok(MycOperationAuditKind::DiscoveryHandlerFetch), - "discovery_handler_publish" => Ok(MycOperationAuditKind::DiscoveryHandlerPublish), - "discovery_handler_compare" => Ok(MycOperationAuditKind::DiscoveryHandlerCompare), - "discovery_handler_refresh" => Ok(MycOperationAuditKind::DiscoveryHandlerRefresh), - "discovery_handler_repair" => Ok(MycOperationAuditKind::DiscoveryHandlerRepair), - other => Err(MycError::InvalidOperation(format!( - "unknown sqlite runtime audit operation `{other}`" - ))), - } -} - -fn operation_outcome_label(value: MycOperationAuditOutcome) -> &'static str { - match value { - MycOperationAuditOutcome::Succeeded => "succeeded", - MycOperationAuditOutcome::Rejected => "rejected", - MycOperationAuditOutcome::Restored => "restored", - MycOperationAuditOutcome::Unavailable => "unavailable", - MycOperationAuditOutcome::Missing => "missing", - MycOperationAuditOutcome::Matched => "matched", - MycOperationAuditOutcome::Drifted => "drifted", - MycOperationAuditOutcome::Conflicted => "conflicted", - MycOperationAuditOutcome::Skipped => "skipped", - } -} - -fn parse_operation_outcome(value: &str) -> Result<MycOperationAuditOutcome, MycError> { - match value { - "succeeded" => Ok(MycOperationAuditOutcome::Succeeded), - "rejected" => Ok(MycOperationAuditOutcome::Rejected), - "restored" => Ok(MycOperationAuditOutcome::Restored), - "unavailable" => Ok(MycOperationAuditOutcome::Unavailable), - "missing" => Ok(MycOperationAuditOutcome::Missing), - "matched" => Ok(MycOperationAuditOutcome::Matched), - "drifted" => Ok(MycOperationAuditOutcome::Drifted), - "conflicted" => Ok(MycOperationAuditOutcome::Conflicted), - "skipped" => Ok(MycOperationAuditOutcome::Skipped), - other => Err(MycError::InvalidOperation(format!( - "unknown sqlite runtime audit outcome `{other}`" - ))), - } -} - -fn parse_delivery_policy(value: &str) -> Result<MycTransportDeliveryPolicy, MycError> { - match value { - "any" => Ok(MycTransportDeliveryPolicy::Any), - "quorum" => Ok(MycTransportDeliveryPolicy::Quorum), - "all" => Ok(MycTransportDeliveryPolicy::All), - other => Err(MycError::InvalidOperation(format!( - "unknown sqlite runtime audit delivery policy `{other}`" - ))), - } -} - -#[cfg(test)] -mod tests { - use crate::signer::prelude::RadrootsNostrSignerConnectionId; - use crate::sql::SqlExecutor; - use serde_json::Value; - - use crate::audit::{ - MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord, - MycOperationAuditStore, - }; - use crate::config::MycAuditConfig; - - use super::{MycOperationAuditSqliteDb, MycSqliteOperationAuditStore}; - - fn config() -> MycAuditConfig { - MycAuditConfig { - default_read_limit: 10, - max_active_file_bytes: 512, - max_archived_files: 2, - } - } - - fn query_values( - store: &MycSqliteOperationAuditStore, - sql: &str, - ) -> Vec<serde_json::Map<String, Value>> { - let raw = store.db.executor().query_raw(sql, "[]").expect("query"); - serde_json::from_str(&raw).expect("rows") - } - - #[test] - fn open_memory_bootstraps_runtime_audit_schema() { - let db = MycOperationAuditSqliteDb::open_memory().expect("open memory db"); - db.migrate_up().expect("rerun migrations"); - - let raw = db - .executor() - .query_raw( - "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name", - "[]", - ) - .expect("query"); - let tables: Vec<serde_json::Map<String, Value>> = - serde_json::from_str(&raw).expect("table rows"); - let table_names = tables - .into_iter() - .filter_map(|row| { - row.get("name") - .and_then(Value::as_str) - .map(ToOwned::to_owned) - }) - .collect::<Vec<_>>(); - assert!(table_names.iter().any(|name| name == "__migrations")); - assert!(table_names.iter().any(|name| name == "myc_operation_audit")); - } - - #[test] - fn append_and_list_records_roundtrip_through_sqlite() { - let store = MycSqliteOperationAuditStore::open_memory(config()).expect("sqlite store"); - let connection_id = - RadrootsNostrSignerConnectionId::parse("connection-1").expect("connection id"); - - store - .append( - &MycOperationAuditRecord::new( - MycOperationAuditKind::ConnectAcceptPublish, - MycOperationAuditOutcome::Rejected, - Some(&connection_id), - Some("request-1"), - 2, - 0, - "0/2 relays acknowledged publish; failures: relay-a: rejected", - ) - .with_attempt_id("attempt-1"), - ) - .expect("append rejected record"); - store - .append(&MycOperationAuditRecord::new( - MycOperationAuditKind::AuthReplayRestore, - MycOperationAuditOutcome::Restored, - Some(&connection_id), - Some("request-1"), - 0, - 0, - "restored pending auth challenge after replay publish rejection", - )) - .expect("append restored record"); - - let records = store.list().expect("list records"); - assert_eq!(records.len(), 2); - assert_eq!( - records[0].operation, - MycOperationAuditKind::ConnectAcceptPublish - ); - assert_eq!(records[0].outcome, MycOperationAuditOutcome::Rejected); - assert_eq!(records[0].attempt_id.as_deref(), Some("attempt-1")); - - let connection_records = store - .list_for_connection(&connection_id) - .expect("list connection records"); - assert_eq!(connection_records, records); - } - - #[test] - fn list_for_attempt_and_latest_attempt_work_with_sqlite() { - let store = MycSqliteOperationAuditStore::open_memory(config()).expect("sqlite store"); - - store - .append( - &MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerRefresh, - MycOperationAuditOutcome::Rejected, - None, - None, - 2, - 0, - "first attempt rejected", - ) - .with_attempt_id("attempt-1"), - ) - .expect("append first attempt"); - store - .append( - &MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerRefresh, - MycOperationAuditOutcome::Succeeded, - None, - None, - 1, - 1, - "second attempt succeeded", - ) - .with_attempt_id("attempt-2"), - ) - .expect("append second attempt"); - - let attempt_records = store - .list_for_attempt_id("attempt-1") - .expect("list attempt records"); - assert_eq!(attempt_records.len(), 1); - assert_eq!( - store - .latest_attempt_id_for_operation(MycOperationAuditKind::DiscoveryHandlerRefresh) - .expect("latest attempt"), - Some("attempt-2".to_owned()) - ); - } - - #[test] - fn file_backed_store_reopens_existing_audit_records() { - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("audit"); - { - let store = MycSqliteOperationAuditStore::open(&path, config()).expect("open store"); - store - .append( - &MycOperationAuditRecord::new( - MycOperationAuditKind::ListenerResponsePublish, - MycOperationAuditOutcome::Succeeded, - None, - Some("request-1"), - 1, - 1, - "relay acknowledged publish", - ) - .with_attempt_id("attempt-1"), - ) - .expect("append"); - } - - let reopened = MycSqliteOperationAuditStore::open(&path, config()).expect("reopen store"); - assert_eq!(reopened.list().expect("reopened list").len(), 1); - assert!(reopened.path().ends_with("operations.sqlite")); - assert_eq!( - reopened - .latest_attempt_id_for_operation(MycOperationAuditKind::ListenerResponsePublish) - .expect("latest attempt"), - Some("attempt-1".to_owned()) - ); - } - - #[test] - fn file_database_uses_wal_mode() { - let temp = tempfile::tempdir().expect("tempdir"); - let store = - MycSqliteOperationAuditStore::open(temp.path().join("audit"), config()).expect("open"); - - let rows = query_values(&store, "PRAGMA journal_mode"); - assert_eq!( - rows.into_iter() - .next() - .and_then(|row| row.get("journal_mode").cloned()) - .and_then(|value| value.as_str().map(ToOwned::to_owned)) - .expect("journal mode"), - "wal" - ); - } - - #[test] - fn migrate_down_and_up_roundtrip_restores_schema() { - let db = MycOperationAuditSqliteDb::open_memory().expect("open memory db"); - db.migrate_down().expect("migrate down"); - - let raw = db - .executor() - .query_raw( - "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name", - "[]", - ) - .expect("query"); - let tables: Vec<serde_json::Map<String, Value>> = - serde_json::from_str(&raw).expect("table rows"); - let table_names = tables - .into_iter() - .filter_map(|row| { - row.get("name") - .and_then(Value::as_str) - .map(ToOwned::to_owned) - }) - .collect::<Vec<_>>(); - assert_eq!(table_names, vec!["__migrations".to_owned()]); - - db.migrate_up().expect("migrate up"); - let raw = db - .executor() - .query_raw("SELECT COUNT(*) AS row_count FROM __migrations", "[]") - .expect("migration count"); - let rows: Vec<serde_json::Map<String, Value>> = - serde_json::from_str(&raw).expect("migration rows"); - assert_eq!( - rows.into_iter() - .next() - .and_then(|row| row.get("row_count").cloned()) - .and_then(|value| value.as_i64()) - .expect("migration row count"), - 1 - ); - } -} diff --git a/src/config.rs b/src/config.rs @@ -1,1492 +0,0 @@ -use std::collections::BTreeSet; -use std::net::SocketAddr; -use std::path::PathBuf; - -use crate::nostr_contract::RadrootsNostrRelayUrl; -use crate::signer::prelude::RadrootsNostrSignerApprovalRequirement; -use nostr::PublicKey; -use radroots_nostr_connect::permission::Permissions; -use serde::{Deserialize, Serialize}; -use tracing_subscriber::EnvFilter; - -use crate::MycBootstrapProfileV1; -use crate::error::MycError; -pub use crate::paths::MycPathsConfig; -use crate::runtime_context::MycRuntimeContext; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MycConfig { - pub logging: MycLoggingConfig, - pub custody: MycCustodyConfig, - pub(crate) paths: MycPathsConfig, - pub persistence: MycPersistenceConfig, - pub audit: MycAuditConfig, - pub observability: MycObservabilityConfig, - pub discovery: MycDiscoveryConfig, - pub policy: MycPolicyConfig, - pub transport: MycTransportConfig, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct MycLoggingConfig { - pub filter: String, - pub output_dir: Option<PathBuf>, - pub stdout: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct MycCustodyConfig { - pub external_command_timeout_secs: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct MycPersistenceConfig { - pub signer_state_backend: MycSignerStateBackend, - pub runtime_audit_backend: MycRuntimeAuditBackend, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct MycAuditConfig { - pub default_read_limit: usize, - pub max_active_file_bytes: u64, - pub max_archived_files: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct MycObservabilityConfig { - pub enabled: bool, - pub bind_addr: SocketAddr, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct MycDiscoveryConfig { - pub enabled: bool, - pub domain: Option<String>, - pub handler_identifier: String, - pub app_identity_backend: Option<MycIdentityBackend>, - pub app_identity_path: Option<PathBuf>, - pub app_identity_keyring_account_id: Option<String>, - pub app_identity_keyring_service_name: Option<String>, - pub app_identity_profile_path: Option<PathBuf>, - pub public_relays: Vec<String>, - pub publish_relays: Vec<String>, - pub nostrconnect_url_template: Option<String>, - pub nip05_output_path: Option<PathBuf>, - pub metadata: MycDiscoveryMetadataConfig, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct MycDiscoveryMetadataConfig { - pub name: Option<String>, - pub display_name: Option<String>, - pub about: Option<String>, - pub website: Option<String>, - pub picture: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct MycTransportConfig { - pub enabled: bool, - pub connect_timeout_secs: u64, - pub relays: Vec<String>, - pub delivery_policy: MycTransportDeliveryPolicy, - pub delivery_quorum: Option<usize>, - pub publish_max_attempts: usize, - pub publish_initial_backoff_millis: u64, - pub publish_max_backoff_millis: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MycConnectionApproval { - NotRequired, - ExplicitUser, - Deny, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MycIdentityBackend { - #[default] - EncryptedFile, - HostVault, - ManagedAccount, - ExternalCommand, - PlaintextFile, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MycSignerStateBackend { - JsonFile, - Sqlite, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MycRuntimeAuditBackend { - JsonlFile, - Sqlite, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MycIdentitySourceSpec { - pub backend: MycIdentityBackend, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path: Option<PathBuf>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub keyring_account_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub keyring_service_name: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile_path: Option<PathBuf>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycRuntimeContractOutput { - pub active_profile: MycBootstrapProfileV1, - pub allowed_profiles: Vec<MycBootstrapProfileV1>, - pub default_shared_secret_backend: MycIdentityBackend, - pub allowed_shared_secret_backends: Vec<MycIdentityBackend>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub runtime_specific_custody_modes: Vec<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub host_vault_policy: Option<String>, - pub path_overrides: MycRuntimePathOverrideContractOutput, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycRuntimePathOverrideContractOutput { - pub canonical_root_selection: String, - pub canonical_subordinate_path_override: String, - pub leaf_path_env_posture: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MycTransportDeliveryPolicy { - Any, - Quorum, - All, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct MycPolicyConfig { - pub connection_approval: MycConnectionApproval, - pub trusted_client_pubkeys: Vec<String>, - pub denied_client_pubkeys: Vec<String>, - pub permission_ceiling: Permissions, - pub allowed_sign_event_kinds: Vec<u16>, - pub auth_url: Option<String>, - pub auth_pending_ttl_secs: u64, - pub auth_authorized_ttl_secs: Option<u64>, - pub reauth_after_inactivity_secs: Option<u64>, - pub connect_rate_limit_window_secs: Option<u64>, - pub connect_rate_limit_max_attempts: Option<usize>, - pub auth_challenge_rate_limit_window_secs: Option<u64>, - pub auth_challenge_rate_limit_max_attempts: Option<usize>, -} - -impl Default for MycLoggingConfig { - fn default() -> Self { - Self { - filter: "info,myc=info".to_owned(), - output_dir: None, - stdout: true, - } - } -} - -impl Default for MycCustodyConfig { - fn default() -> Self { - Self { - external_command_timeout_secs: 10, - } - } -} - -impl Default for MycTransportConfig { - fn default() -> Self { - Self { - enabled: false, - connect_timeout_secs: 10, - relays: Vec::new(), - delivery_policy: MycTransportDeliveryPolicy::Any, - delivery_quorum: None, - publish_max_attempts: 1, - publish_initial_backoff_millis: 250, - publish_max_backoff_millis: 2_000, - } - } -} - -impl Default for MycPersistenceConfig { - fn default() -> Self { - Self { - signer_state_backend: MycSignerStateBackend::JsonFile, - runtime_audit_backend: MycRuntimeAuditBackend::JsonlFile, - } - } -} - -impl Default for MycAuditConfig { - fn default() -> Self { - Self { - default_read_limit: 200, - max_active_file_bytes: 262_144, - max_archived_files: 8, - } - } -} - -impl Default for MycObservabilityConfig { - fn default() -> Self { - Self { - enabled: false, - bind_addr: "127.0.0.1:9460" - .parse() - .expect("default observability bind addr"), - } - } -} - -impl Default for MycDiscoveryConfig { - fn default() -> Self { - Self { - enabled: false, - domain: None, - handler_identifier: "myc".to_owned(), - app_identity_backend: None, - app_identity_path: None, - app_identity_keyring_account_id: None, - app_identity_keyring_service_name: None, - app_identity_profile_path: None, - public_relays: Vec::new(), - publish_relays: Vec::new(), - nostrconnect_url_template: None, - nip05_output_path: None, - metadata: MycDiscoveryMetadataConfig::default(), - } - } -} - -impl Default for MycPolicyConfig { - fn default() -> Self { - Self { - connection_approval: MycConnectionApproval::ExplicitUser, - trusted_client_pubkeys: Vec::new(), - denied_client_pubkeys: Vec::new(), - permission_ceiling: Permissions::default(), - allowed_sign_event_kinds: Vec::new(), - auth_url: None, - auth_pending_ttl_secs: 900, - auth_authorized_ttl_secs: None, - reauth_after_inactivity_secs: None, - connect_rate_limit_window_secs: None, - connect_rate_limit_max_attempts: None, - auth_challenge_rate_limit_window_secs: None, - auth_challenge_rate_limit_max_attempts: None, - } - } -} - -impl MycConnectionApproval { - pub fn into_signer_approval_requirement(self) -> RadrootsNostrSignerApprovalRequirement { - match self { - Self::NotRequired => RadrootsNostrSignerApprovalRequirement::NotRequired, - Self::ExplicitUser | Self::Deny => RadrootsNostrSignerApprovalRequirement::ExplicitUser, - } - } -} - -impl MycTransportDeliveryPolicy { - pub fn as_str(self) -> &'static str { - match self { - Self::Any => "any", - Self::Quorum => "quorum", - Self::All => "all", - } - } -} - -impl MycIdentityBackend { - pub fn as_str(self) -> &'static str { - match self { - Self::EncryptedFile => "encrypted_file", - Self::HostVault => "host_vault", - Self::ManagedAccount => "managed_account", - Self::ExternalCommand => "external_command", - Self::PlaintextFile => "plaintext_file", - } - } -} - -const MYC_ALLOWED_PROFILES: [MycBootstrapProfileV1; 3] = [ - MycBootstrapProfileV1::Interactive, - MycBootstrapProfileV1::ServiceHost, - MycBootstrapProfileV1::RepoLocal, -]; -const MYC_ALLOWED_SHARED_SECRET_BACKENDS: [MycIdentityBackend; 4] = [ - MycIdentityBackend::EncryptedFile, - MycIdentityBackend::HostVault, - MycIdentityBackend::ExternalCommand, - MycIdentityBackend::PlaintextFile, -]; -const MYC_RUNTIME_SPECIFIC_CUSTODY_MODES: [&str; 1] = ["managed_account"]; -const MYC_DEFAULT_SHARED_SECRET_BACKEND: MycIdentityBackend = MycIdentityBackend::EncryptedFile; -const MYC_HOST_VAULT_POLICY: &str = "desktop"; -const MYC_CANONICAL_ROOT_SELECTION: &str = "bootstrap_cli"; -const MYC_CANONICAL_SUBORDINATE_PATH_OVERRIDE: &str = "config_document_cli_only"; -const MYC_LEAF_PATH_ENV_POSTURE: &str = "forbidden"; - -impl MycRuntimeContractOutput { - pub fn for_active_profile(active_profile: MycBootstrapProfileV1) -> Self { - Self { - active_profile, - allowed_profiles: MYC_ALLOWED_PROFILES.to_vec(), - default_shared_secret_backend: MYC_DEFAULT_SHARED_SECRET_BACKEND, - allowed_shared_secret_backends: MYC_ALLOWED_SHARED_SECRET_BACKENDS.to_vec(), - runtime_specific_custody_modes: MYC_RUNTIME_SPECIFIC_CUSTODY_MODES - .into_iter() - .map(str::to_owned) - .collect(), - host_vault_policy: Some(MYC_HOST_VAULT_POLICY.to_owned()), - path_overrides: MycRuntimePathOverrideContractOutput { - canonical_root_selection: MYC_CANONICAL_ROOT_SELECTION.to_owned(), - canonical_subordinate_path_override: MYC_CANONICAL_SUBORDINATE_PATH_OVERRIDE - .to_owned(), - leaf_path_env_posture: MYC_LEAF_PATH_ENV_POSTURE.to_owned(), - }, - } - } -} - -impl MycSignerStateBackend { - pub fn as_str(self) -> &'static str { - match self { - Self::JsonFile => "json_file", - Self::Sqlite => "sqlite", - } - } -} - -impl MycRuntimeAuditBackend { - pub fn as_str(self) -> &'static str { - match self { - Self::JsonlFile => "jsonl_file", - Self::Sqlite => "sqlite", - } - } -} - -impl MycConfig { - pub fn allowed_profiles() -> Vec<MycBootstrapProfileV1> { - MYC_ALLOWED_PROFILES.to_vec() - } - - pub fn default_shared_secret_backend() -> MycIdentityBackend { - MYC_DEFAULT_SHARED_SECRET_BACKEND - } - - pub fn allowed_shared_secret_backends() -> Vec<MycIdentityBackend> { - MYC_ALLOWED_SHARED_SECRET_BACKENDS.to_vec() - } - - pub fn runtime_specific_custody_modes() -> Vec<String> { - MYC_RUNTIME_SPECIFIC_CUSTODY_MODES - .into_iter() - .map(str::to_owned) - .collect() - } - - pub fn host_vault_policy() -> Option<String> { - Some(MYC_HOST_VAULT_POLICY.to_owned()) - } - - pub fn runtime_contract_output(&self) -> MycRuntimeContractOutput { - MycRuntimeContractOutput::for_active_profile(self.paths.runtime_context().profile()) - } - - #[must_use] - pub fn from_runtime_context(runtime_context: MycRuntimeContext) -> Self { - let logs_dir = runtime_context.context().paths().logs().to_path_buf(); - let nip05_output_path = runtime_context - .context() - .paths() - .state() - .join("public/.well-known/nostr.json"); - let logging = MycLoggingConfig { - output_dir: Some(logs_dir), - ..MycLoggingConfig::default() - }; - let discovery = MycDiscoveryConfig { - nip05_output_path: Some(nip05_output_path), - ..MycDiscoveryConfig::default() - }; - Self { - logging, - custody: MycCustodyConfig::default(), - paths: MycPathsConfig::from_runtime_context(runtime_context), - persistence: MycPersistenceConfig::default(), - audit: MycAuditConfig::default(), - observability: MycObservabilityConfig::default(), - discovery, - policy: MycPolicyConfig::default(), - transport: MycTransportConfig::default(), - } - } - - #[must_use] - pub fn runtime_context(&self) -> &MycRuntimeContext { - self.paths.runtime_context() - } - - #[must_use] - pub fn paths(&self) -> &MycPathsConfig { - &self.paths - } - - pub fn validate(&self) -> Result<(), MycError> { - if self.logging.filter.trim().is_empty() { - return Err(MycError::InvalidConfig( - "logging.filter must not be empty".to_owned(), - )); - } - - EnvFilter::try_new(self.logging.filter.clone()).map_err(|source| { - MycError::InvalidLogFilter { - filter: self.logging.filter.clone(), - source, - } - })?; - - if let Some(output_dir) = self.logging.output_dir.as_ref() - && output_dir.as_os_str().is_empty() - { - return Err(MycError::InvalidConfig( - "logging.output_dir must not be empty when set".to_owned(), - )); - } - - if self.custody.external_command_timeout_secs == 0 { - return Err(MycError::InvalidConfig( - "custody.external_command_timeout_secs must be greater than zero".to_owned(), - )); - } - - validate_identity_source_config( - "paths.signer_identity", - &self.paths.signer_identity_source(), - )?; - validate_identity_source_config("paths.user_identity", &self.paths.user_identity_source())?; - - if self.audit.default_read_limit == 0 { - return Err(MycError::InvalidConfig( - "audit.default_read_limit must be greater than zero".to_owned(), - )); - } - - if self.audit.max_active_file_bytes == 0 { - return Err(MycError::InvalidConfig( - "audit.max_active_file_bytes must be greater than zero".to_owned(), - )); - } - - if !self.observability.bind_addr.ip().is_loopback() { - return Err(MycError::InvalidConfig( - "observability.bind_addr must use a loopback address".to_owned(), - )); - } - - self.discovery.validate(&self.transport)?; - - if self.transport.connect_timeout_secs == 0 { - return Err(MycError::InvalidConfig( - "transport.connect_timeout_secs must be greater than zero".to_owned(), - )); - } - - if self.transport.publish_max_attempts == 0 { - return Err(MycError::InvalidConfig( - "transport.publish_max_attempts must be greater than zero".to_owned(), - )); - } - - if self.transport.publish_initial_backoff_millis == 0 { - return Err(MycError::InvalidConfig( - "transport.publish_initial_backoff_millis must be greater than zero".to_owned(), - )); - } - - if self.transport.publish_max_backoff_millis == 0 { - return Err(MycError::InvalidConfig( - "transport.publish_max_backoff_millis must be greater than zero".to_owned(), - )); - } - - if self.transport.publish_initial_backoff_millis > self.transport.publish_max_backoff_millis - { - return Err(MycError::InvalidConfig( - "transport.publish_max_backoff_millis must be greater than or equal to transport.publish_initial_backoff_millis" - .to_owned(), - )); - } - - if self.policy.auth_pending_ttl_secs == 0 { - return Err(MycError::InvalidConfig( - "policy.auth_pending_ttl_secs must be greater than zero".to_owned(), - )); - } - if self - .policy - .auth_authorized_ttl_secs - .is_some_and(|ttl| ttl == 0) - { - return Err(MycError::InvalidConfig( - "policy.auth_authorized_ttl_secs must be greater than zero when set".to_owned(), - )); - } - if self - .policy - .reauth_after_inactivity_secs - .is_some_and(|ttl| ttl == 0) - { - return Err(MycError::InvalidConfig( - "policy.reauth_after_inactivity_secs must be greater than zero when set".to_owned(), - )); - } - if (self.policy.auth_authorized_ttl_secs.is_some() - || self.policy.reauth_after_inactivity_secs.is_some()) - && self.policy.auth_url.is_none() - { - return Err(MycError::InvalidConfig( - "policy.auth_url must be set when automatic auth TTL policy is configured" - .to_owned(), - )); - } - validate_optional_rate_limit( - "policy.connect_rate_limit", - self.policy.connect_rate_limit_window_secs, - self.policy.connect_rate_limit_max_attempts, - )?; - validate_optional_rate_limit( - "policy.auth_challenge_rate_limit", - self.policy.auth_challenge_rate_limit_window_secs, - self.policy.auth_challenge_rate_limit_max_attempts, - )?; - - let trusted_client_pubkeys = - normalize_policy_client_pubkeys(&self.policy.trusted_client_pubkeys)?; - let denied_client_pubkeys = - normalize_policy_client_pubkeys(&self.policy.denied_client_pubkeys)?; - let overlap = trusted_client_pubkeys - .intersection(&denied_client_pubkeys) - .cloned() - .collect::<Vec<_>>(); - if !overlap.is_empty() { - return Err(MycError::InvalidConfig(format!( - "policy trusted and denied client pubkeys overlap: {}", - overlap.join(", ") - ))); - } - - match self.transport.delivery_policy { - MycTransportDeliveryPolicy::Quorum => { - let Some(delivery_quorum) = self.transport.delivery_quorum else { - return Err(MycError::InvalidConfig( - "transport.delivery_quorum must be set when transport.delivery_policy is `quorum`" - .to_owned(), - )); - }; - if delivery_quorum == 0 { - return Err(MycError::InvalidConfig( - "transport.delivery_quorum must be greater than zero".to_owned(), - )); - } - } - MycTransportDeliveryPolicy::Any | MycTransportDeliveryPolicy::All => { - if self.transport.delivery_quorum.is_some() { - return Err(MycError::InvalidConfig( - "transport.delivery_quorum is only valid when transport.delivery_policy is `quorum`" - .to_owned(), - )); - } - } - } - - let parsed_relays = self.transport.parse_relays()?; - if self.transport.enabled && parsed_relays.is_empty() { - return Err(MycError::InvalidConfig( - "transport.relays must not be empty when transport.enabled is true".to_owned(), - )); - } - - Ok(()) - } -} - -#[cfg(test)] -pub(crate) fn test_config(repo_local_root: &std::path::Path) -> MycConfig { - use std::ffi::OsString; - - use crate::{ - RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, parse_myc_cli_v1_from, - resolve_myc_runtime_context, - }; - - let invocation = parse_myc_cli_v1_from(vec![ - OsString::from("myc"), - OsString::from("--profile"), - OsString::from("repo-local"), - OsString::from("--instance"), - OsString::from("test"), - OsString::from("--repo-local-root"), - repo_local_root.as_os_str().to_owned(), - OsString::from("run"), - ]) - .expect("test CLI selection"); - let resolver = - RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); - let context = - resolve_myc_runtime_context(&resolver, &invocation).expect("test runtime context"); - MycConfig::from_runtime_context(context) -} - -fn validate_optional_rate_limit( - label: &str, - window_secs: Option<u64>, - max_attempts: Option<usize>, -) -> Result<(), MycError> { - match (window_secs, max_attempts) { - (None, None) => Ok(()), - (Some(window_secs), Some(max_attempts)) => { - if window_secs == 0 { - return Err(MycError::InvalidConfig(format!( - "{label}.window_secs must be greater than zero when set" - ))); - } - if max_attempts == 0 { - return Err(MycError::InvalidConfig(format!( - "{label}.max_attempts must be greater than zero when set" - ))); - } - Ok(()) - } - _ => Err(MycError::InvalidConfig(format!( - "{label}.window_secs and {label}.max_attempts must be set together" - ))), - } -} - -fn normalize_policy_client_pubkeys(values: &[String]) -> Result<BTreeSet<String>, MycError> { - values - .iter() - .map(|value| { - let public_key = PublicKey::parse(value) - .or_else(|_| PublicKey::from_hex(value)) - .map_err(|_| { - MycError::InvalidConfig(format!( - "policy client pubkey `{value}` is not a valid nostr public key" - )) - })?; - Ok(public_key.to_hex()) - }) - .collect() -} - -fn validate_identity_source_config( - label: &str, - source: &MycIdentitySourceSpec, -) -> Result<(), MycError> { - match source.backend { - MycIdentityBackend::EncryptedFile | MycIdentityBackend::PlaintextFile => { - let Some(path) = source.path.as_ref() else { - return Err(MycError::InvalidConfig(format!( - "{label}.path must be set when backend is `{}`", - source.backend.as_str() - ))); - }; - if path.as_os_str().is_empty() { - return Err(MycError::InvalidConfig(format!( - "{label}.path must not be empty when backend is `{}`", - source.backend.as_str() - ))); - } - if source.keyring_account_id.is_some() { - return Err(MycError::InvalidConfig(format!( - "{label}.keyring_account_id must not be set when backend is `{}`", - source.backend.as_str() - ))); - } - if source.keyring_service_name.is_some() { - return Err(MycError::InvalidConfig(format!( - "{label}.keyring_service_name must not be set when backend is `{}`", - source.backend.as_str() - ))); - } - if source.profile_path.is_some() { - return Err(MycError::InvalidConfig(format!( - "{label}.profile_path must not be set when backend is `{}`", - source.backend.as_str() - ))); - } - } - MycIdentityBackend::ExternalCommand => { - let Some(path) = source.path.as_ref() else { - return Err(MycError::InvalidConfig(format!( - "{label}.path must be set when backend is `external_command`" - ))); - }; - if path.as_os_str().is_empty() { - return Err(MycError::InvalidConfig(format!( - "{label}.path must not be empty when backend is `external_command`" - ))); - } - if source.keyring_account_id.is_some() { - return Err(MycError::InvalidConfig(format!( - "{label}.keyring_account_id must not be set when backend is `external_command`" - ))); - } - if source.keyring_service_name.is_some() { - return Err(MycError::InvalidConfig(format!( - "{label}.keyring_service_name must not be set when backend is `external_command`" - ))); - } - if source.profile_path.is_some() { - return Err(MycError::InvalidConfig(format!( - "{label}.profile_path must not be set when backend is `external_command`" - ))); - } - } - MycIdentityBackend::HostVault => { - let Some(account_id) = source.keyring_account_id.as_deref() else { - return Err(MycError::InvalidConfig(format!( - "{label}.keyring_account_id must be set when backend is `host_vault`" - ))); - }; - let _ = crate::host_identity::RadrootsIdentityId::parse(account_id).map_err(|_| { - MycError::InvalidConfig(format!( - "{label}.keyring_account_id must be a valid nostr public identity id" - )) - })?; - let Some(service_name) = source.keyring_service_name.as_deref() else { - return Err(MycError::InvalidConfig(format!( - "{label}.keyring_service_name must be set when backend is `host_vault`" - ))); - }; - if service_name.trim().is_empty() { - return Err(MycError::InvalidConfig(format!( - "{label}.keyring_service_name must not be empty when backend is `host_vault`" - ))); - } - if let Some(profile_path) = source.profile_path.as_ref() - && profile_path.as_os_str().is_empty() - { - return Err(MycError::InvalidConfig(format!( - "{label}.profile_path must not be empty when set" - ))); - } - } - MycIdentityBackend::ManagedAccount => { - let Some(path) = source.path.as_ref() else { - return Err(MycError::InvalidConfig(format!( - "{label}.path must be set when backend is `managed_account`" - ))); - }; - if path.as_os_str().is_empty() { - return Err(MycError::InvalidConfig(format!( - "{label}.path must not be empty when backend is `managed_account`" - ))); - } - let Some(service_name) = source.keyring_service_name.as_deref() else { - return Err(MycError::InvalidConfig(format!( - "{label}.keyring_service_name must be set when backend is `managed_account`" - ))); - }; - if service_name.trim().is_empty() { - return Err(MycError::InvalidConfig(format!( - "{label}.keyring_service_name must not be empty when backend is `managed_account`" - ))); - } - if source.keyring_account_id.is_some() { - return Err(MycError::InvalidConfig(format!( - "{label}.keyring_account_id must not be set when backend is `managed_account`" - ))); - } - if source.profile_path.is_some() { - return Err(MycError::InvalidConfig(format!( - "{label}.profile_path must not be set when backend is `managed_account`" - ))); - } - } - } - - Ok(()) -} - -impl MycTransportConfig { - pub fn parse_relays(&self) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> { - self.relays - .iter() - .map(|value| { - RadrootsNostrRelayUrl::parse(value).map_err(|source| { - MycError::InvalidConfig(format!( - "transport.relays contains invalid relay url `{value}`: {source}" - )) - }) - }) - .collect() - } -} - -impl MycDiscoveryConfig { - pub fn app_identity_source(&self) -> Option<MycIdentitySourceSpec> { - let backend = match (self.app_identity_backend, self.app_identity_path.as_ref()) { - (Some(backend), _) => Some(backend), - (None, Some(_)) => Some(MycIdentityBackend::EncryptedFile), - (None, None) => None, - }?; - - Some(MycIdentitySourceSpec { - backend, - path: match backend { - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ManagedAccount - | MycIdentityBackend::ExternalCommand => self.app_identity_path.clone(), - MycIdentityBackend::HostVault => None, - }, - keyring_account_id: match backend { - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ManagedAccount - | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::HostVault => self.app_identity_keyring_account_id.clone(), - }, - keyring_service_name: match backend { - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::HostVault | MycIdentityBackend::ManagedAccount => { - self.app_identity_keyring_service_name.clone() - } - }, - profile_path: match backend { - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ManagedAccount - | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::HostVault => self.app_identity_profile_path.clone(), - }, - }) - } - - pub fn parse_public_relays(&self) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> { - parse_discovery_relays(&self.public_relays, "discovery.public_relays") - } - - pub fn parse_publish_relays(&self) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> { - parse_discovery_relays(&self.publish_relays, "discovery.publish_relays") - } - - pub fn resolved_public_relays( - &self, - transport: &MycTransportConfig, - ) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> { - let relays = if self.public_relays.is_empty() { - transport.parse_relays()? - } else { - self.parse_public_relays()? - }; - Ok(normalize_discovery_relays(relays)) - } - - pub fn resolved_publish_relays( - &self, - transport: &MycTransportConfig, - ) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> { - let relays = if self.publish_relays.is_empty() { - self.resolved_public_relays(transport)? - } else { - self.parse_publish_relays()? - }; - Ok(normalize_discovery_relays(relays)) - } - - fn validate(&self, transport: &MycTransportConfig) -> Result<(), MycError> { - if !self.enabled { - return Ok(()); - } - - let domain = self.domain.as_deref().ok_or_else(|| { - MycError::InvalidConfig( - "discovery.domain must be set when discovery.enabled is true".to_owned(), - ) - })?; - validate_discovery_domain(domain)?; - - if self.handler_identifier.trim().is_empty() { - return Err(MycError::InvalidConfig( - "discovery.handler_identifier must not be empty when discovery.enabled is true" - .to_owned(), - )); - } - - if let Some(source) = self.app_identity_source() { - validate_identity_source_config("discovery.app_identity", &source)?; - } - - if let Some(template) = self.nostrconnect_url_template.as_deref() { - validate_nostrconnect_url_template(template)?; - } - - if let Some(path) = self.nip05_output_path.as_ref() - && path.as_os_str().is_empty() - { - return Err(MycError::InvalidConfig( - "discovery.nip05_output_path must not be empty".to_owned(), - )); - } - - if self.resolved_public_relays(transport)?.is_empty() { - return Err(MycError::InvalidConfig( - "discovery requires at least one public relay hint via discovery.public_relays or transport.relays".to_owned(), - )); - } - - let _ = self.resolved_publish_relays(transport)?; - Ok(()) - } -} - -fn parse_discovery_relays( - values: &[String], - field_name: &str, -) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> { - values - .iter() - .map(|value| { - RadrootsNostrRelayUrl::parse(value).map_err(|source| { - MycError::InvalidConfig(format!( - "{field_name} contains invalid relay url `{value}`: {source}" - )) - }) - }) - .collect() -} - -fn normalize_discovery_relays( - mut relays: Vec<RadrootsNostrRelayUrl>, -) -> Vec<RadrootsNostrRelayUrl> { - relays.sort_by(|left, right| left.as_str().cmp(right.as_str())); - relays.dedup_by(|left, right| left.as_str() == right.as_str()); - relays -} - -fn validate_discovery_domain(domain: &str) -> Result<(), MycError> { - let trimmed = domain.trim(); - if trimmed.is_empty() - || trimmed.contains("://") - || trimmed.contains('/') - || trimmed.contains('?') - || trimmed.contains('#') - || trimmed.chars().any(char::is_whitespace) - { - return Err(MycError::InvalidConfig(format!( - "discovery.domain must be a bare host name without scheme or path: `{domain}`" - ))); - } - Ok(()) -} - -fn validate_nostrconnect_url_template(template: &str) -> Result<(), MycError> { - let trimmed = template.trim(); - if trimmed.is_empty() { - return Err(MycError::InvalidConfig( - "discovery.nostrconnect_url_template must not be empty when set".to_owned(), - )); - } - if !trimmed.contains("<nostrconnect>") { - return Err(MycError::InvalidConfig( - "discovery.nostrconnect_url_template must contain the `<nostrconnect>` placeholder" - .to_owned(), - )); - } - let candidate = trimmed.replace("<nostrconnect>", "nostrconnect%3A%2F%2Fclient"); - let url = nostr::Url::parse(&candidate).map_err(|source| { - MycError::InvalidConfig(format!( - "discovery.nostrconnect_url_template is invalid: {source}" - )) - })?; - - match url.scheme() { - "https" => Ok(()), - "http" if discovery_host_is_local(url.host_str()) => Ok(()), - _ => Err(MycError::InvalidConfig( - "discovery.nostrconnect_url_template must use `https://`, except loopback hosts may use `http://`".to_owned(), - )), - } -} - -fn discovery_host_is_local(host: Option<&str>) -> bool { - matches!(host, Some("localhost" | "127.0.0.1" | "::1")) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, parse_myc_cli_v1_from, - resolve_myc_runtime_context, - }; - - fn config_for( - resolver: &RadrootsPathResolver, - profile: &str, - instance: &str, - repo_local_root: Option<&str>, - ) -> MycConfig { - let mut arguments = vec!["myc", "--profile", profile, "--instance", instance]; - if let Some(root) = repo_local_root { - arguments.extend(["--repo-local-root", root]); - } - arguments.push("run"); - let invocation = parse_myc_cli_v1_from(arguments).expect("CLI selection"); - let context = resolve_myc_runtime_context(resolver, &invocation).expect("runtime context"); - MycConfig::from_runtime_context(context) - } - - fn default_config() -> MycConfig { - super::test_config(std::path::Path::new("/repo/.local/radroots")) - } - - #[test] - fn interactive_config_is_context_derived() { - let resolver = RadrootsPathResolver::new( - RadrootsPlatform::Linux, - RadrootsHostEnvironment { - home_dir: Some(PathBuf::from("/home/treesap")), - xdg_runtime_dir: Some(PathBuf::from("/run/user/1000")), - ..RadrootsHostEnvironment::default() - }, - ); - let config = config_for(&resolver, "interactive", "primary", None); - assert_eq!(config.logging.filter, "info,myc=info"); - assert_eq!( - config.runtime_context().profile(), - MycBootstrapProfileV1::Interactive - ); - assert_eq!( - config.runtime_context().context().instance().as_str(), - "primary" - ); - assert_eq!( - config.paths.run_dir(), - PathBuf::from("/run/user/1000/radroots/services/myc/primary") - ); - assert_eq!( - config.logging.output_dir, - Some(PathBuf::from( - "/home/treesap/.local/state/radroots/logs/services/myc/primary" - )) - ); - assert!(config.logging.stdout); - assert_eq!( - config.paths.state_dir(), - PathBuf::from("/home/treesap/.local/share/radroots/services/myc/primary") - ); - assert_eq!( - config.paths.signer_identity_backend, - MycIdentityBackend::EncryptedFile - ); - assert_eq!( - config.paths.signer_identity_path, - PathBuf::from( - "/home/treesap/.config/radroots/secrets/services/myc/primary/signer-identity.json" - ) - ); - assert_eq!(config.paths.signer_identity_keyring_account_id, None); - assert_eq!( - config.paths.signer_identity_keyring_service_name, - "org.radroots.myc.signer" - ); - assert_eq!(config.paths.signer_identity_profile_path, None); - assert_eq!( - config.paths.user_identity_backend, - MycIdentityBackend::EncryptedFile - ); - assert_eq!( - config.paths.user_identity_path, - PathBuf::from( - "/home/treesap/.config/radroots/secrets/services/myc/primary/user-identity.json" - ) - ); - assert_eq!(config.paths.user_identity_keyring_account_id, None); - assert_eq!( - config.paths.user_identity_keyring_service_name, - "org.radroots.myc.user" - ); - assert_eq!(config.paths.user_identity_profile_path, None); - assert_eq!( - config.persistence.signer_state_backend, - MycSignerStateBackend::JsonFile - ); - assert_eq!( - config.persistence.runtime_audit_backend, - MycRuntimeAuditBackend::JsonlFile - ); - assert_eq!( - config.policy.connection_approval, - MycConnectionApproval::ExplicitUser - ); - assert!(config.policy.trusted_client_pubkeys.is_empty()); - assert!(config.policy.denied_client_pubkeys.is_empty()); - assert!(config.policy.permission_ceiling.is_empty()); - assert!(config.policy.allowed_sign_event_kinds.is_empty()); - assert!(config.policy.auth_url.is_none()); - assert_eq!(config.policy.auth_pending_ttl_secs, 900); - assert_eq!(config.policy.auth_authorized_ttl_secs, None); - assert_eq!(config.policy.reauth_after_inactivity_secs, None); - assert_eq!(config.policy.connect_rate_limit_window_secs, None); - assert_eq!(config.policy.connect_rate_limit_max_attempts, None); - assert_eq!(config.policy.auth_challenge_rate_limit_window_secs, None); - assert_eq!(config.policy.auth_challenge_rate_limit_max_attempts, None); - assert_eq!(config.audit.default_read_limit, 200); - assert_eq!(config.audit.max_active_file_bytes, 262_144); - assert_eq!(config.audit.max_archived_files, 8); - assert!(!config.observability.enabled); - assert_eq!( - config.observability.bind_addr, - "127.0.0.1:9460" - .parse() - .expect("default observability bind addr") - ); - assert!(!config.discovery.enabled); - assert_eq!(config.discovery.handler_identifier, "myc"); - assert!(config.discovery.domain.is_none()); - assert_eq!(config.discovery.app_identity_backend, None); - assert!(config.discovery.app_identity_path.is_none()); - assert!(config.discovery.public_relays.is_empty()); - assert!(config.discovery.publish_relays.is_empty()); - assert!(config.discovery.nostrconnect_url_template.is_none()); - assert_eq!( - config.discovery.nip05_output_path, - Some(PathBuf::from( - "/home/treesap/.local/share/radroots/services/myc/primary/public/.well-known/nostr.json" - )) - ); - assert!(!config.transport.enabled); - assert_eq!(config.transport.connect_timeout_secs, 10); - assert!(config.transport.relays.is_empty()); - assert_eq!( - config.transport.delivery_policy, - MycTransportDeliveryPolicy::Any - ); - assert_eq!(config.transport.delivery_quorum, None); - assert_eq!(config.transport.publish_max_attempts, 1); - assert_eq!(config.transport.publish_initial_backoff_millis, 250); - assert_eq!(config.transport.publish_max_backoff_millis, 2_000); - } - - #[test] - fn service_host_profile_uses_canonical_defaults() { - let resolver = - RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); - let config = config_for(&resolver, "service-host", "primary", None); - - assert_eq!( - config.runtime_context().profile(), - MycBootstrapProfileV1::ServiceHost - ); - assert_eq!( - config.logging.output_dir, - Some(PathBuf::from("/var/log/radroots/services/myc/primary")) - ); - assert_eq!( - config.paths.run_dir(), - PathBuf::from("/run/radroots/services/myc/primary") - ); - assert_eq!( - config.paths.state_dir(), - PathBuf::from("/var/lib/radroots/services/myc/primary") - ); - assert_eq!( - config.paths.signer_identity_path, - PathBuf::from("/etc/radroots/secrets/services/myc/primary/signer-identity.json") - ); - assert_eq!( - config.paths.user_identity_path, - PathBuf::from("/etc/radroots/secrets/services/myc/primary/user-identity.json") - ); - assert_eq!( - config.discovery.nip05_output_path, - Some(PathBuf::from( - "/var/lib/radroots/services/myc/primary/public/.well-known/nostr.json" - )) - ); - } - - #[test] - fn repo_local_profile_uses_explicit_repo_local_root() { - let resolver = - RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); - let repo_local_root = PathBuf::from("/repo/.local/radroots/dev/myc"); - let config = config_for( - &resolver, - "repo-local", - "primary", - Some("/repo/.local/radroots/dev/myc"), - ); - - assert_eq!( - config.runtime_context().profile(), - MycBootstrapProfileV1::RepoLocal - ); - assert_eq!( - config.logging.output_dir, - Some(repo_local_root.join("logs/services/myc/primary")) - ); - assert_eq!( - config.paths.run_dir(), - repo_local_root.join("run/services/myc/primary") - ); - assert_eq!( - config.paths.state_dir(), - repo_local_root.join("data/services/myc/primary") - ); - assert_eq!( - config.paths.signer_identity_path, - repo_local_root.join("secrets/services/myc/primary/signer-identity.json") - ); - assert_eq!( - config.paths.user_identity_path, - repo_local_root.join("secrets/services/myc/primary/user-identity.json") - ); - assert_eq!( - config.discovery.nip05_output_path, - Some(repo_local_root.join("data/services/myc/primary/public/.well-known/nostr.json")) - ); - } - - #[test] - fn validate_rejects_enabled_transport_without_relays() { - let mut config = default_config(); - config.transport.enabled = true; - - let err = config.validate().expect_err("missing relays"); - assert!(err.to_string().contains("transport.relays")); - } - - #[test] - fn validate_rejects_zero_audit_read_limit() { - let mut config = default_config(); - config.audit.default_read_limit = 0; - - let err = config.validate().expect_err("invalid audit read limit"); - assert!(err.to_string().contains("audit.default_read_limit")); - } - - #[test] - fn validate_rejects_zero_external_command_timeout() { - let mut config = default_config(); - config.custody.external_command_timeout_secs = 0; - - let err = config.validate().expect_err("invalid custody timeout"); - assert!( - err.to_string() - .contains("custody.external_command_timeout_secs") - ); - } - - #[test] - fn validate_rejects_non_loopback_observability_bind_addr() { - let mut config = default_config(); - config.observability.enabled = true; - config.observability.bind_addr = "0.0.0.0:9460" - .parse() - .expect("non-loopback observability bind addr"); - - let err = config - .validate() - .expect_err("non-loopback observability bind addr should be rejected"); - assert!( - err.to_string() - .contains("observability.bind_addr must use a loopback address") - ); - } - - #[test] - fn discovery_validation_requires_domain_and_relays_when_enabled() { - let mut config = default_config(); - config.discovery.enabled = true; - config.transport.enabled = true; - config.transport.relays = vec!["wss://relay.example.com".to_owned()]; - - let err = config.validate().expect_err("missing discovery domain"); - assert!(err.to_string().contains("discovery.domain")); - - config.discovery.domain = Some("myc.example.com".to_owned()); - config.transport.relays.clear(); - let err = config.validate().expect_err("missing relay hints"); - assert!(err.to_string().contains("at least one public relay hint")); - } - - #[test] - fn discovery_validation_allows_localhost_http_nostrconnect_template() { - let mut config = default_config(); - config.discovery.enabled = true; - config.discovery.domain = Some("localhost".to_owned()); - config.discovery.public_relays = vec!["ws://localhost:8080".to_owned()]; - config.discovery.nostrconnect_url_template = - Some("http://localhost/connect?uri=<nostrconnect>".to_owned()); - - config.validate().expect("localhost http template"); - } - - #[test] - fn discovery_validation_rejects_invalid_nostrconnect_template() { - let mut config = default_config(); - config.discovery.enabled = true; - config.discovery.domain = Some("myc.example.com".to_owned()); - config.discovery.public_relays = vec!["wss://relay.example.com".to_owned()]; - config.discovery.nostrconnect_url_template = Some("http://bad.example.com".to_owned()); - - let err = config.validate().expect_err("invalid discovery template"); - assert!( - err.to_string() - .contains("discovery.nostrconnect_url_template") - ); - } - - #[test] - fn validate_rejects_invalid_delivery_policy_settings() { - let mut config = default_config(); - config.transport.enabled = true; - config.transport.relays = vec!["wss://relay.example.com".to_owned()]; - config.transport.delivery_policy = MycTransportDeliveryPolicy::Quorum; - - let err = config - .validate() - .expect_err("missing quorum should be rejected"); - assert!(err.to_string().contains("transport.delivery_quorum")); - - config.transport.delivery_quorum = Some(0); - let err = config - .validate() - .expect_err("zero quorum should be rejected"); - assert!(err.to_string().contains("greater than zero")); - - config.transport.delivery_policy = MycTransportDeliveryPolicy::Any; - config.transport.delivery_quorum = Some(1); - let err = config - .validate() - .expect_err("quorum on non-quorum policy should be rejected"); - assert!(err.to_string().contains("only valid")); - } - - #[test] - fn validate_rejects_invalid_publish_retry_settings() { - let mut config = default_config(); - config.transport.publish_max_attempts = 0; - let err = config.validate().expect_err("zero attempts"); - assert!(err.to_string().contains("publish_max_attempts")); - - config.transport.publish_max_attempts = 1; - config.transport.publish_initial_backoff_millis = 0; - let err = config.validate().expect_err("zero initial backoff"); - assert!(err.to_string().contains("publish_initial_backoff_millis")); - - config.transport.publish_initial_backoff_millis = 10; - config.transport.publish_max_backoff_millis = 0; - let err = config.validate().expect_err("zero max backoff"); - assert!(err.to_string().contains("publish_max_backoff_millis")); - - config.transport.publish_max_backoff_millis = 5; - let err = config - .validate() - .expect_err("max backoff less than initial"); - assert!(err.to_string().contains("greater than or equal")); - } - - #[test] - fn validate_rejects_overlapping_policy_client_lists() { - let mut config = default_config(); - config.policy.trusted_client_pubkeys = - vec!["1111111111111111111111111111111111111111111111111111111111111111".to_owned()]; - config.policy.denied_client_pubkeys = - vec!["1111111111111111111111111111111111111111111111111111111111111111".to_owned()]; - - let err = config - .validate() - .expect_err("overlapping policy client lists"); - assert!(err.to_string().contains("overlap")); - } - - #[test] - fn validate_requires_auth_url_for_auth_ttl_policy() { - let mut config = default_config(); - config.policy.auth_authorized_ttl_secs = Some(60); - - let err = config.validate().expect_err("missing auth url"); - assert!(err.to_string().contains("policy.auth_url")); - } - - #[test] - fn validate_requires_complete_rate_limit_pairs() { - let mut config = default_config(); - config.policy.connect_rate_limit_window_secs = Some(60); - - let err = config - .validate() - .expect_err("incomplete connect rate limit"); - assert!(err.to_string().contains("policy.connect_rate_limit")); - - let mut config = default_config(); - config.policy.auth_challenge_rate_limit_max_attempts = Some(2); - - let err = config - .validate() - .expect_err("incomplete auth challenge rate limit"); - assert!(err.to_string().contains("policy.auth_challenge_rate_limit")); - } - - #[test] - fn runtime_contract_output_matches_shared_runtime_contract() { - let config = default_config(); - let contract = config.runtime_contract_output(); - - assert_eq!(contract.active_profile, MycBootstrapProfileV1::RepoLocal); - assert_eq!(contract.allowed_profiles, MycConfig::allowed_profiles()); - assert_eq!( - contract.default_shared_secret_backend, - MycConfig::default_shared_secret_backend() - ); - assert_eq!( - contract.allowed_shared_secret_backends, - MycConfig::allowed_shared_secret_backends() - ); - assert_eq!( - contract.runtime_specific_custody_modes, - MycConfig::runtime_specific_custody_modes() - ); - assert_eq!(contract.host_vault_policy, MycConfig::host_vault_policy()); - assert_eq!( - contract.path_overrides.canonical_root_selection, - "bootstrap_cli" - ); - assert_eq!( - contract.path_overrides.canonical_subordinate_path_override, - "config_document_cli_only" - ); - assert_eq!(contract.path_overrides.leaf_path_env_posture, "forbidden"); - } -} diff --git a/src/control.rs b/src/control.rs @@ -1,888 +0,0 @@ -use std::str::FromStr; - -use crate::signer::prelude::{ - RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerBackend, - RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord, - RadrootsNostrSignerPublishTransition, RadrootsNostrSignerPublishWorkflowRecord, - RadrootsNostrSignerRequestId, RadrootsNostrSignerWorkflowId, -}; -use radroots_nostr_connect::{Permission, Request, Response, permission::Permissions, uri::Uri}; -use serde::Serialize; - -use crate::app::MycRuntime; -use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; -use crate::error::MycError; -use crate::outbox::{MycDeliveryOutboxKind, MycDeliveryOutboxRecord}; -use crate::transport::{MycNip46Handler, MycNostrTransport, MycPublishOutcome}; - -#[derive(Debug, Serialize)] -pub struct MycAuthorizedReplayOutput { - pub connection: RadrootsNostrSignerConnectionRecord, - pub replayed_request_id: Option<String>, -} - -#[derive(Debug, Serialize)] -pub struct MycAcceptedConnectionOutput { - pub connection: RadrootsNostrSignerConnectionRecord, - pub response_request_id: String, - pub response_relays: Vec<String>, -} - -pub async fn authorize_auth_challenge( - runtime: &MycRuntime, - connection_id: &RadrootsNostrSignerConnectionId, -) -> Result<MycAuthorizedReplayOutput, MycError> { - let backend = runtime.signer_backend(); - let connection = backend.get_connection(connection_id)?.ok_or_else(|| { - MycError::InvalidOperation(format!("connection `{connection_id}` was not found")) - })?; - runtime - .signer_context() - .policy() - .ensure_authorize_auth_challenge_allowed(&connection)?; - let workflow = workflow_from_transition( - backend.begin_auth_replay_publish_finalization(connection_id)?, - "auth replay", - )?; - let replayed_request_id = - replay_authorized_request(runtime, &connection.connection_id, &workflow.workflow_id) - .await?; - let connection = runtime - .signer_backend() - .get_connection(connection_id)? - .ok_or_else(|| { - MycError::InvalidOperation(format!("connection `{connection_id}` was not found")) - })?; - Ok(MycAuthorizedReplayOutput { - connection, - replayed_request_id, - }) -} - -pub async fn accept_client_uri( - runtime: &MycRuntime, - uri: &str, -) -> Result<MycAcceptedConnectionOutput, MycError> { - let Some(transport) = runtime.transport() else { - return Err(MycError::InvalidOperation( - "transport.enabled must be true to accept client nostrconnect URIs".to_owned(), - )); - }; - let preferred_relays = transport.relays().to_vec(); - if preferred_relays.is_empty() { - return Err(MycError::InvalidOperation( - "transport.relays must not be empty to accept client nostrconnect URIs".to_owned(), - )); - } - - let client_uri = match Uri::parse(uri)? { - Uri::Client(client_uri) => client_uri, - Uri::Bunker(_) => { - return Err(MycError::InvalidOperation( - "connect accept requires a nostrconnect:// client URI".to_owned(), - )); - } - }; - let client_public_key = - radroots_nostr::key::public_key_to_nostr(client_uri.client_public_key()).map_err(|_| { - MycError::InvalidOperation("NIP-46 client public key conversion failed".to_owned()) - })?; - - let request = Request::Connect { - remote_signer_public_key: runtime.signer_identity().public_identity().public_key(), - secret: Some(client_uri.secret().to_owned()), - requested_permissions: client_uri.metadata().requested_permissions().clone(), - client_metadata: (!client_uri.metadata().is_display_empty()) - .then(|| client_uri.metadata().clone()), - }; - let backend = runtime.signer_backend(); - let Some(approval_requirement) = runtime - .signer_context() - .policy() - .approval_requirement_for_client(&client_public_key) - else { - return Err(MycError::InvalidOperation( - "client public key denied by policy".to_owned(), - )); - }; - let connection = match backend.evaluate_connect_request(client_public_key, request)? { - crate::signer::prelude::RadrootsNostrSignerConnectEvaluation::ExistingConnection( - connection, - ) => { - if connection.connect_secret_is_consumed() { - return Err(MycError::InvalidOperation( - "connect secret has already been consumed by a successful connection" - .to_owned(), - )); - } - if runtime - .signer_context() - .policy() - .approval_requirement_for_client(&connection.client_public_key) - .is_none() - { - return Err(MycError::InvalidOperation( - "client public key denied by policy".to_owned(), - )); - } - connection - } - crate::signer::prelude::RadrootsNostrSignerConnectEvaluation::RegistrationRequired( - proposal, - ) => { - let requested_permissions = runtime - .signer_context() - .policy() - .filtered_requested_permissions(&proposal.requested_permissions); - let draft = proposal - .into_connection_draft(runtime.user_public_identity()) - .with_requested_permissions(requested_permissions) - .with_relays(preferred_relays.clone()) - .with_approval_requirement(approval_requirement); - let connection = backend.register_connection(draft)?; - if approval_requirement == RadrootsNostrSignerApprovalRequirement::NotRequired { - let granted_permissions = runtime - .signer_context() - .policy() - .auto_granted_permissions(&connection.requested_permissions); - let _ = backend - .set_granted_permissions(&connection.connection_id, granted_permissions)?; - } - Box::new(connection) - } - }; - - let handler = MycNip46Handler::new(runtime.signer_context(), preferred_relays.clone()); - let response_request_id = RadrootsNostrSignerRequestId::new_v7().into_string(); - let event = handler.build_response_event( - client_public_key, - response_request_id.clone(), - Response::ConnectSecretEcho(client_uri.secret().to_owned()), - )?; - let client_relays = client_uri - .relays() - .iter() - .map(|relay| { - nostr::RelayUrl::parse(&relay.to_string()) - .expect("NIP-46 client URI relays were already validated") - }) - .collect::<Vec<_>>(); - let response_relays = merge_relays(&client_relays, &preferred_relays); - let workflow = workflow_from_transition( - backend.begin_connect_secret_publish_finalization(&connection.connection_id)?, - "connect accept", - )?; - let event = match runtime - .signer_identity() - .sign_protocol_event_builder(event, "connect accept response") - { - Ok(event) => event, - Err(error) => { - return Err(cancel_connect_accept_workflow_on_error( - runtime, - &workflow.workflow_id, - MycError::InvalidOperation(format!( - "failed to sign connect accept response event: {error}" - )), - )); - } - }; - let outbox_record = match build_control_outbox_record( - MycDeliveryOutboxKind::ConnectAcceptPublish, - event.clone(), - &response_relays, - Some(&connection.connection_id), - Some(response_request_id.as_str()), - Some(&workflow.workflow_id), - ) { - Ok(record) => record, - Err(error) => { - return Err(cancel_connect_accept_workflow_on_error( - runtime, - &workflow.workflow_id, - error, - )); - } - }; - if let Err(error) = runtime.delivery_outbox_store().enqueue(&outbox_record) { - return Err(cancel_connect_accept_workflow_on_error( - runtime, - &workflow.workflow_id, - error, - )); - } - let publish_outcome = match MycNostrTransport::publish_event_once( - runtime.signer_identity(), - &response_relays, - &runtime.config().transport, - "connect accept response publish", - &event, - ) - .await - { - Ok(outcome) => outcome, - Err(error) => { - let error = mark_outbox_publish_failed(runtime, &outbox_record, error); - runtime.record_operation_audit(&record_publish_failure( - MycOperationAuditKind::ConnectAcceptPublish, - Some(&connection.connection_id), - Some(response_request_id.as_str()), - response_relays.len(), - &error, - )); - return Err(cancel_connect_accept_workflow_on_error( - runtime, - &workflow.workflow_id, - error, - )); - } - }; - if let Err(error) = backend.mark_publish_workflow_published(&workflow.workflow_id) { - record_post_publish_failure( - runtime, - MycOperationAuditKind::ConnectAcceptPublish, - Some(&connection.connection_id), - Some(response_request_id.as_str()), - &publish_outcome, - format!("failed to mark connect-accept publish workflow as published: {error}"), - ); - return Err(error.into()); - } - if let Err(error) = runtime - .delivery_outbox_store() - .mark_published_pending_finalize(&outbox_record.job_id, publish_outcome.attempt_count) - { - record_post_publish_failure( - runtime, - MycOperationAuditKind::ConnectAcceptPublish, - Some(&connection.connection_id), - Some(response_request_id.as_str()), - &publish_outcome, - format!("failed to persist connect-accept outbox published state: {error}"), - ); - return Err(error); - } - if let Err(error) = backend.finalize_publish_workflow(&workflow.workflow_id) { - record_post_publish_failure( - runtime, - MycOperationAuditKind::ConnectAcceptPublish, - Some(&connection.connection_id), - Some(response_request_id.as_str()), - &publish_outcome, - format!("failed to finalize connect-accept publish workflow: {error}"), - ); - return Err(error.into()); - } - if let Err(error) = runtime - .delivery_outbox_store() - .mark_finalized(&outbox_record.job_id) - { - record_post_publish_failure( - runtime, - MycOperationAuditKind::ConnectAcceptPublish, - Some(&connection.connection_id), - Some(response_request_id.as_str()), - &publish_outcome, - format!("failed to finalize connect-accept outbox job: {error}"), - ); - return Err(error); - } - record_publish_audit( - runtime, - MycOperationAuditKind::ConnectAcceptPublish, - MycOperationAuditOutcome::Succeeded, - Some(&connection.connection_id), - Some(response_request_id.as_str()), - &publish_outcome, - ); - - Ok(MycAcceptedConnectionOutput { - connection: backend - .get_connection(&connection.connection_id)? - .ok_or_else(|| { - MycError::InvalidOperation("accepted connection was not persisted".to_owned()) - })?, - response_request_id, - response_relays: response_relays.iter().map(ToString::to_string).collect(), - }) -} - -pub fn parse_permission_values(values: &[String]) -> Result<Permissions, MycError> { - let mut permissions = Vec::new(); - for value in values { - for fragment in value.split(',') { - let trimmed = fragment.trim(); - if trimmed.is_empty() { - continue; - } - permissions.push(Permission::from_str(trimmed)?); - } - } - permissions.sort(); - permissions.dedup(); - Ok(permissions.into()) -} - -async fn replay_authorized_request( - runtime: &MycRuntime, - connection_id: &RadrootsNostrSignerConnectionId, - workflow_id: &RadrootsNostrSignerWorkflowId, -) -> Result<Option<String>, MycError> { - let backend = runtime.signer_backend(); - let workflow = backend.get_publish_workflow(workflow_id)?.ok_or_else(|| { - MycError::InvalidOperation(format!("publish workflow `{workflow_id}` was not found")) - })?; - let Some(pending_request) = workflow.pending_request.clone() else { - return Ok(None); - }; - let transport = match runtime.transport() { - Some(transport) => transport, - None => { - let error = MycError::InvalidOperation( - "transport.enabled must be true to replay authorized requests".to_owned(), - ); - return Err(cancel_auth_replay_workflow_on_error( - runtime, - connection_id, - workflow_id, - Some(&pending_request.request_message.id), - error, - )); - } - }; - let handler = MycNip46Handler::new(runtime.signer_context(), transport.relays().to_vec()); - let evaluation = match backend.evaluate_auth_replay_publish_workflow(workflow_id) { - Ok(evaluation) => evaluation, - Err(error) => { - return Err(cancel_auth_replay_workflow_on_error( - runtime, - connection_id, - workflow_id, - Some(&pending_request.request_message.id), - error.into(), - )); - } - }; - let handled_outcome = match handler - .handle_authorized_request_evaluation(pending_request.request_message.clone(), evaluation) - { - Ok(handled_outcome) => handled_outcome, - Err(error) => { - return Err(cancel_auth_replay_workflow_on_error( - runtime, - connection_id, - workflow_id, - Some(&pending_request.request_message.id), - error, - )); - } - }; - if let Some(audit) = handled_outcome.audit.as_ref() { - runtime.signer_context().record_signer_request_audit(audit); - } - let Some((response, _, consume_connect_secret_for)) = - handled_outcome.handled_request.into_publish_parts() - else { - let error = MycError::InvalidOperation( - "authorized auth replay did not produce a response".to_owned(), - ); - return Err(cancel_auth_replay_workflow_on_error( - runtime, - connection_id, - workflow_id, - Some(&pending_request.request_message.id), - error, - )); - }; - if consume_connect_secret_for.is_some() { - return Err(cancel_auth_replay_workflow_on_error( - runtime, - connection_id, - workflow_id, - Some(&pending_request.request_message.id), - MycError::InvalidOperation( - "auth replay unexpectedly requested connect-secret finalization".to_owned(), - ), - )); - } - let event = match handler.build_response_event( - backend - .get_connection(connection_id)? - .ok_or_else(|| { - MycError::InvalidOperation(format!("connection `{connection_id}` was not found")) - })? - .client_public_key, - pending_request.request_message.id.clone(), - response, - ) { - Ok(event) => event, - Err(error) => { - return Err(cancel_auth_replay_workflow_on_error( - runtime, - connection_id, - workflow_id, - Some(&pending_request.request_message.id), - error, - )); - } - }; - let connection = backend.get_connection(connection_id)?.ok_or_else(|| { - MycError::InvalidOperation(format!("connection `{connection_id}` was not found")) - })?; - let event = match runtime - .signer_identity() - .sign_protocol_event_builder(event, "authorized auth replay response") - { - Ok(event) => event, - Err(error) => { - return Err(cancel_auth_replay_workflow_on_error( - runtime, - connection_id, - workflow_id, - Some(&pending_request.request_message.id), - MycError::InvalidOperation(format!( - "failed to sign authorized auth replay response event: {error}" - )), - )); - } - }; - let publish_relays = if connection.relays.is_empty() { - transport.relays().to_vec() - } else { - connection.relays.clone() - }; - let outbox_record = match build_control_outbox_record( - MycDeliveryOutboxKind::AuthReplayPublish, - event.clone(), - &publish_relays, - Some(connection_id), - Some(&pending_request.request_message.id), - Some(workflow_id), - ) { - Ok(record) => record, - Err(error) => { - return Err(cancel_auth_replay_workflow_on_error( - runtime, - connection_id, - workflow_id, - Some(&pending_request.request_message.id), - error, - )); - } - }; - if let Err(error) = runtime.delivery_outbox_store().enqueue(&outbox_record) { - return Err(cancel_auth_replay_workflow_on_error( - runtime, - connection_id, - workflow_id, - Some(&pending_request.request_message.id), - error, - )); - } - let publish_outcome = match MycNostrTransport::publish_event_once( - runtime.signer_identity(), - &publish_relays, - &runtime.config().transport, - "authorized auth replay publish", - &event, - ) - .await - { - Ok(publish_outcome) => publish_outcome, - Err(error) => { - let error = mark_outbox_publish_failed(runtime, &outbox_record, error); - runtime.record_operation_audit(&record_publish_failure( - MycOperationAuditKind::AuthReplayPublish, - Some(connection_id), - Some(pending_request.request_message.id.as_str()), - publish_relays.len(), - &error, - )); - return Err(cancel_auth_replay_workflow_on_error( - runtime, - connection_id, - workflow_id, - Some(&pending_request.request_message.id), - error, - )); - } - }; - if let Err(error) = backend.mark_publish_workflow_published(workflow_id) { - record_post_publish_failure( - runtime, - MycOperationAuditKind::AuthReplayPublish, - Some(connection_id), - Some(pending_request.request_message.id.as_str()), - &publish_outcome, - format!("failed to mark auth replay publish workflow as published: {error}"), - ); - return Err(error.into()); - } - if let Err(error) = runtime - .delivery_outbox_store() - .mark_published_pending_finalize(&outbox_record.job_id, publish_outcome.attempt_count) - { - record_post_publish_failure( - runtime, - MycOperationAuditKind::AuthReplayPublish, - Some(connection_id), - Some(pending_request.request_message.id.as_str()), - &publish_outcome, - format!("failed to persist auth replay outbox published state: {error}"), - ); - return Err(error); - } - if let Err(error) = backend.finalize_publish_workflow(workflow_id) { - record_post_publish_failure( - runtime, - MycOperationAuditKind::AuthReplayPublish, - Some(connection_id), - Some(pending_request.request_message.id.as_str()), - &publish_outcome, - format!("failed to finalize auth replay publish workflow: {error}"), - ); - return Err(error.into()); - } - if let Err(error) = runtime - .delivery_outbox_store() - .mark_finalized(&outbox_record.job_id) - { - record_post_publish_failure( - runtime, - MycOperationAuditKind::AuthReplayPublish, - Some(connection_id), - Some(pending_request.request_message.id.as_str()), - &publish_outcome, - format!("failed to finalize auth replay outbox job: {error}"), - ); - return Err(error); - } - record_publish_audit( - runtime, - MycOperationAuditKind::AuthReplayPublish, - MycOperationAuditOutcome::Succeeded, - Some(connection_id), - Some(pending_request.request_message.id.as_str()), - &publish_outcome, - ); - Ok(Some(pending_request.request_message.id.clone())) -} - -fn cancel_auth_replay_workflow_on_error( - runtime: &MycRuntime, - connection_id: &RadrootsNostrSignerConnectionId, - workflow_id: &RadrootsNostrSignerWorkflowId, - request_id: Option<&str>, - error: MycError, -) -> MycError { - let summary = publish_failure_summary(&error); - match runtime - .signer_backend() - .cancel_publish_workflow(workflow_id) - .map_err(MycError::from) - { - Ok(_) => { - let mut record = MycOperationAuditRecord::new( - MycOperationAuditKind::AuthReplayRestore, - MycOperationAuditOutcome::Restored, - Some(connection_id), - request_id, - error - .publish_rejection_counts() - .map(|(relay_count, _)| relay_count) - .unwrap_or_default(), - error - .publish_rejection_counts() - .map(|(_, acknowledged)| acknowledged) - .unwrap_or_default(), - format!("preserved pending auth challenge after replay failure: {summary}"), - ); - if let ( - Some(delivery_policy), - Some(required_acknowledged_relay_count), - Some(attempt_count), - ) = ( - error.publish_delivery_policy(), - error.publish_required_acknowledged_relay_count(), - error.publish_attempt_count(), - ) { - record = record.with_delivery_details( - delivery_policy, - required_acknowledged_relay_count, - attempt_count, - ); - } - runtime.record_operation_audit(&record); - error - } - Err(restore_error) => MycError::InvalidOperation(format!( - "{error}; additionally failed to cancel auth replay publish workflow: {restore_error}" - )), - } -} - -fn cancel_connect_accept_workflow_on_error( - runtime: &MycRuntime, - workflow_id: &RadrootsNostrSignerWorkflowId, - error: MycError, -) -> MycError { - match runtime - .signer_backend() - .cancel_publish_workflow(workflow_id) - .map(|_| ()) - .map_err(MycError::from) - { - Ok(()) => error, - Err(cancel_error) => MycError::InvalidOperation(format!( - "{error}; additionally failed to cancel connect-accept publish workflow: {cancel_error}" - )), - } -} - -fn workflow_from_transition( - transition: RadrootsNostrSignerPublishTransition, - operation: &str, -) -> Result<RadrootsNostrSignerPublishWorkflowRecord, MycError> { - transition.workflow().cloned().ok_or_else(|| { - MycError::InvalidOperation(format!( - "{operation} publish workflow did not return a workflow record" - )) - }) -} - -fn build_control_outbox_record( - kind: MycDeliveryOutboxKind, - event: crate::nostr_contract::RadrootsNostrEvent, - relay_urls: &[nostr::RelayUrl], - connection_id: Option<&RadrootsNostrSignerConnectionId>, - request_id: Option<&str>, - workflow_id: Option<&RadrootsNostrSignerWorkflowId>, -) -> Result<MycDeliveryOutboxRecord, MycError> { - let relay_urls = relay_urls.to_vec(); - let mut record = MycDeliveryOutboxRecord::new(kind, event, relay_urls)?; - if let Some(connection_id) = connection_id { - record = record.with_connection_id(connection_id); - } - if let Some(request_id) = request_id { - record = record.with_request_id(request_id.to_owned()); - } - if let Some(workflow_id) = workflow_id { - record = record.with_signer_publish_workflow_id(workflow_id); - } - Ok(record) -} - -fn mark_outbox_publish_failed( - runtime: &MycRuntime, - outbox_record: &MycDeliveryOutboxRecord, - error: MycError, -) -> MycError { - let publish_attempt_count = error.publish_attempt_count().unwrap_or_default(); - let summary = publish_failure_summary(&error); - match runtime.delivery_outbox_store().mark_failed( - &outbox_record.job_id, - publish_attempt_count, - &summary, - ) { - Ok(_) => error, - Err(outbox_error) => MycError::InvalidOperation(format!( - "{error}; additionally failed to persist publish failure to the delivery outbox: {outbox_error}" - )), - } -} - -fn record_publish_audit( - runtime: &MycRuntime, - operation: MycOperationAuditKind, - outcome: MycOperationAuditOutcome, - connection_id: Option<&RadrootsNostrSignerConnectionId>, - request_id: Option<&str>, - publish_outcome: &MycPublishOutcome, -) { - runtime.record_operation_audit( - &MycOperationAuditRecord::new( - operation, - outcome, - connection_id, - request_id, - publish_outcome.relay_count, - publish_outcome.acknowledged_relay_count, - publish_outcome.relay_outcome_summary.clone(), - ) - .with_delivery_details( - publish_outcome.delivery_policy, - publish_outcome.required_acknowledged_relay_count, - publish_outcome.attempt_count, - ), - ); -} - -fn record_post_publish_failure( - runtime: &MycRuntime, - operation: MycOperationAuditKind, - connection_id: Option<&RadrootsNostrSignerConnectionId>, - request_id: Option<&str>, - publish_outcome: &MycPublishOutcome, - summary: impl Into<String>, -) { - runtime.record_operation_audit( - &MycOperationAuditRecord::new( - operation, - MycOperationAuditOutcome::Rejected, - connection_id, - request_id, - publish_outcome.relay_count, - publish_outcome.acknowledged_relay_count, - summary.into(), - ) - .with_delivery_details( - publish_outcome.delivery_policy, - publish_outcome.required_acknowledged_relay_count, - publish_outcome.attempt_count, - ), - ); -} - -fn publish_failure_summary(error: &MycError) -> String { - error - .publish_rejection_details() - .map(ToOwned::to_owned) - .unwrap_or_else(|| error.to_string()) -} - -fn record_publish_failure( - operation: MycOperationAuditKind, - connection_id: Option<&RadrootsNostrSignerConnectionId>, - request_id: Option<&str>, - relay_count: usize, - error: &MycError, -) -> MycOperationAuditRecord { - let mut record = MycOperationAuditRecord::new( - operation, - MycOperationAuditOutcome::Rejected, - connection_id, - request_id, - relay_count, - error - .publish_rejection_counts() - .map(|(_, acknowledged)| acknowledged) - .unwrap_or_default(), - publish_failure_summary(error), - ); - if let (Some(delivery_policy), Some(required_acknowledged_relay_count), Some(attempt_count)) = ( - error.publish_delivery_policy(), - error.publish_required_acknowledged_relay_count(), - error.publish_attempt_count(), - ) { - record = record.with_delivery_details( - delivery_policy, - required_acknowledged_relay_count, - attempt_count, - ); - } - record -} - -fn merge_relays( - primary: &[nostr::RelayUrl], - secondary: &[nostr::RelayUrl], -) -> Vec<nostr::RelayUrl> { - let mut relays = primary.to_vec(); - relays.extend_from_slice(secondary); - relays.sort_by(|left, right| left.as_str().cmp(right.as_str())); - relays.dedup_by(|left, right| left.as_str() == right.as_str()); - relays -} - -#[cfg(test)] -mod tests { - use super::{accept_client_uri, authorize_auth_challenge}; - use crate::app::MycRuntime; - use crate::config::{MycConfig, MycConnectionApproval}; - use crate::host_identity::RadrootsIdentity; - use std::path::PathBuf; - use std::thread; - use std::time::Duration; - - fn write_identity(path: &std::path::Path, secret_key: &str) { - let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); - crate::identity_files::store_encrypted_identity(path, &identity).expect("save identity"); - } - - fn runtime_with_config<F>(approval: MycConnectionApproval, configure: F) -> MycRuntime - where - F: FnOnce(&mut MycConfig), - { - let temp = tempfile::tempdir().expect("tempdir").keep(); - let mut config = crate::config::test_config(PathBuf::from(&temp).as_path()); - config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json"); - config.paths.user_identity_path = PathBuf::from(&temp).join("user.json"); - config.policy.connection_approval = approval; - config.transport.enabled = true; - config.transport.relays = vec!["ws://127.0.0.1:65500".to_owned()]; - configure(&mut config); - write_identity( - &config.paths.signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_identity( - &config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - MycRuntime::bootstrap(config).expect("runtime") - } - - #[tokio::test(flavor = "current_thread")] - async fn authorize_auth_challenge_rejects_expired_pending_challenge() { - let runtime = runtime_with_config(MycConnectionApproval::ExplicitUser, |config| { - config.policy.auth_pending_ttl_secs = 1; - }); - let manager = runtime.signer_manager().expect("manager"); - let connection = manager - .register_connection( - crate::signer::prelude::RadrootsNostrSignerConnectionDraft::new( - nostr::Keys::generate().public_key(), - runtime.user_public_identity(), - ), - ) - .expect("register connection"); - manager - .require_auth_challenge(&connection.connection_id, "https://auth.example") - .expect("require auth challenge"); - - thread::sleep(Duration::from_secs(2)); - - let error = authorize_auth_challenge(&runtime, &connection.connection_id) - .await - .expect_err("expired auth challenge should be rejected"); - assert!(error.to_string().contains("auth challenge expired")); - } - - #[tokio::test(flavor = "current_thread")] - async fn accept_client_uri_rejects_denied_client_pubkeys() { - let denied_identity = RadrootsIdentity::from_secret_key_str( - "3333333333333333333333333333333333333333333333333333333333333333", - ) - .expect("identity"); - let runtime = runtime_with_config(MycConnectionApproval::ExplicitUser, |config| { - config.policy.denied_client_pubkeys = vec![denied_identity.public_key().to_hex()]; - }); - let mut query = url::form_urlencoded::Serializer::new(String::new()); - query.append_pair("relay", "ws://127.0.0.1:65500"); - query.append_pair("secret", "client-secret"); - let uri = format!( - "nostrconnect://{}?{}", - denied_identity.final_public_key(), - query.finish() - ); - - let error = accept_client_uri(&runtime, &uri) - .await - .expect_err("denied client should be rejected"); - assert!( - error - .to_string() - .contains("client public key denied by policy") - ); - } -} diff --git a/src/discovery.rs b/src/discovery.rs @@ -1,2321 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use crate::nostr_contract::{ - RadrootsNostrApplicationHandlerSpec, RadrootsNostrEvent, RadrootsNostrFilter, - RadrootsNostrKind, RadrootsNostrMetadata, RadrootsNostrRelayUrl, - radroots_nostr_build_application_handler_event, radroots_nostr_filter_tag, - radroots_nostr_metadata_has_fields, radroots_nostr_tag_first_value, -}; -use crate::signer::prelude::RadrootsNostrSignerRequestId; -use radroots_nostr_connect::uri::Uri; -use serde::{Deserialize, Serialize}; -use tokio::task::JoinSet; - -use crate::app::MycRuntime; -use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; -use crate::config::MycDiscoveryMetadataConfig; -use crate::custody::{MycActiveIdentity, MycIdentityProvider}; -use crate::error::MycError; -use crate::outbox::{MycDeliveryOutboxKind, MycDeliveryOutboxRecord}; -use crate::transport::{MycNostrTransport, MycPublishOutcome, MycRelayPublishResult}; - -const NIP46_RPC_KIND: u32 = 24_133; -const DISCOVERY_BUNDLE_VERSION: u32 = 1; -const DISCOVERY_BUNDLE_MANIFEST_FILE_NAME: &str = "bundle.json"; -const DISCOVERY_BUNDLE_NIP89_FILE_NAME: &str = "nip89-handler.json"; -const DISCOVERY_BUNDLE_NIP05_RELATIVE_PATH: &str = ".well-known/nostr.json"; -const DISCOVERY_RELAY_FETCH_CONCURRENCY_LIMIT: usize = 8; - -#[derive(Clone)] -pub struct MycDiscoveryContext { - app_identity: MycActiveIdentity, - signer_identity: MycActiveIdentity, - domain: String, - handler_identifier: String, - public_relays: Vec<RadrootsNostrRelayUrl>, - publish_relays: Vec<RadrootsNostrRelayUrl>, - nostrconnect_url: Option<String>, - metadata: Option<RadrootsNostrMetadata>, - nip05_output_path: Option<PathBuf>, - connect_timeout_secs: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MycNip05Document { - pub names: BTreeMap<String, String>, - pub nip46: MycNip05DocumentSection, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MycNip05DocumentSection { - pub relays: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub nostrconnect_url: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycRenderedNip05Output { - pub domain: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub output_path: Option<PathBuf>, - pub document: MycNip05Document, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycRenderedNip89Output { - pub author_public_key_hex: String, - pub signer_public_key_hex: String, - pub publish_relays: Vec<String>, - pub event: RadrootsNostrEvent, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycPublishedNip89Output { - pub author_public_key_hex: String, - pub signer_public_key_hex: String, - pub publish_relays: Vec<String>, - pub relay_count: usize, - pub acknowledged_relay_count: usize, - pub relay_outcome_summary: String, - pub relay_results: Vec<MycRelayPublishResult>, - pub event: RadrootsNostrEvent, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum MycDiscoveryRepairOutcome { - Repaired, - Failed, - Unchanged, - Skipped, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] -pub struct MycDiscoveryRepairSummary { - pub repaired: usize, - pub failed: usize, - pub unchanged: usize, - pub skipped: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycDiscoveryRelayRepairResult { - pub relay_url: String, - pub outcome: MycDiscoveryRepairOutcome, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub detail: Option<String>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MycDiscoveryLiveStatus { - Missing, - Matched, - Drifted, - Conflicted, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MycNormalizedNip89Handler { - pub author_public_key_hex: String, - pub kinds: Vec<u32>, - pub identifier: String, - pub relays: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub nostrconnect_url: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option<RadrootsNostrMetadata>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MycLiveNip89Event { - pub event_id_hex: String, - pub created_at_unix: u64, - pub source_relays: Vec<String>, - pub handler: MycNormalizedNip89Handler, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MycLiveNip89Group { - pub handler: MycNormalizedNip89Handler, - pub source_relays: Vec<String>, - pub events: Vec<MycLiveNip89Event>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycLiveNip89RelayState { - pub relay_url: String, - pub fetch_status: MycDiscoveryRelayFetchStatus, - #[serde(skip_serializing_if = "Option::is_none")] - pub fetch_error: Option<String>, - pub live_groups: Vec<MycLiveNip89Group>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MycDiscoveryRelayFetchStatus { - Available, - Unavailable, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycDiscoveryRelayState { - pub relay_url: String, - pub fetch_status: MycDiscoveryRelayFetchStatus, - #[serde(skip_serializing_if = "Option::is_none")] - pub fetch_error: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub live_status: Option<MycDiscoveryLiveStatus>, - pub differing_fields: Vec<String>, - pub live_groups: Vec<MycLiveNip89Group>, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] -pub struct MycDiscoveryRelaySummary { - pub total_relays: usize, - pub unavailable_relays: Vec<String>, - pub missing_relays: Vec<String>, - pub matched_relays: Vec<String>, - pub drifted_relays: Vec<String>, - pub conflicted_relays: Vec<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycFetchedLiveNip89Output { - pub author_public_key_hex: String, - pub publish_relays: Vec<String>, - pub handler_identifier: String, - pub live_groups: Vec<MycLiveNip89Group>, - pub relay_states: Vec<MycLiveNip89RelayState>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycDiscoveryDiffOutput { - pub status: MycDiscoveryLiveStatus, - pub local_handler: MycNormalizedNip89Handler, - pub live_groups: Vec<MycLiveNip89Group>, - pub relay_states: Vec<MycDiscoveryRelayState>, - pub relay_summary: MycDiscoveryRelaySummary, - pub differing_fields: Vec<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycRefreshedNip89Output { - pub attempt_id: String, - pub status: MycDiscoveryLiveStatus, - pub force: bool, - pub differing_fields: Vec<String>, - pub live_groups: Vec<MycLiveNip89Group>, - pub relay_states: Vec<MycDiscoveryRelayState>, - pub relay_summary: MycDiscoveryRelaySummary, - pub repair_summary: MycDiscoveryRepairSummary, - pub repair_results: Vec<MycDiscoveryRelayRepairResult>, - pub remaining_repair_relays: Vec<String>, - pub published: Option<MycPublishedNip89Output>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct MycDiscoveryRefreshPlan { - selected_relays: Vec<RadrootsNostrRelayUrl>, - planned_repair_relays: Vec<String>, -} - -#[derive(Debug, Clone)] -struct MycSourcedLiveNip89Event { - source_relay: String, - event: RadrootsNostrEvent, -} - -#[derive(Debug, Clone)] -struct MycFetchedLiveNip89State { - live_groups: Vec<MycLiveNip89Group>, - relay_states: Vec<MycLiveNip89RelayState>, -} - -#[derive(Debug)] -struct MycRelayFetchTaskOutput { - relay_index: usize, - relay_events: Vec<MycSourcedLiveNip89Event>, - relay_state: MycLiveNip89RelayState, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MycNip89HandlerDocument { - pub kinds: Vec<u32>, - pub identifier: String, - pub relays: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub nostrconnect_url: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option<RadrootsNostrMetadata>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MycDiscoveryBundleManifest { - pub version: u32, - pub domain: String, - pub author_public_key_hex: String, - pub signer_public_key_hex: String, - pub public_relays: Vec<String>, - pub publish_relays: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub nostrconnect_url: Option<String>, - pub nip05_relative_path: String, - pub nip89_relative_path: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycDiscoveryBundleOutput { - pub output_dir: PathBuf, - pub manifest_path: PathBuf, - pub nip05_path: PathBuf, - pub nip89_handler_path: PathBuf, - pub manifest: MycDiscoveryBundleManifest, - pub nip05_document: MycNip05Document, - pub nip89_handler: MycNip89HandlerDocument, -} - -impl MycDiscoveryContext { - pub fn from_runtime(runtime: &MycRuntime) -> Result<Self, MycError> { - let discovery = &runtime.config().discovery; - if !discovery.enabled { - return Err(MycError::InvalidOperation( - "discovery.enabled must be true to use discovery commands".to_owned(), - )); - } - - let app_identity = match discovery.app_identity_source() { - Some(source) => MycIdentityProvider::from_source( - "discovery app", - source, - Duration::from_secs(runtime.config().custody.external_command_timeout_secs), - )? - .load_active_identity()?, - None => runtime.signer_identity().clone(), - }; - let public_relays = discovery.resolved_public_relays(&runtime.config().transport)?; - let publish_relays = discovery.resolved_publish_relays(&runtime.config().transport)?; - let nostrconnect_url = discovery - .nostrconnect_url_template - .as_deref() - .map(|template| { - render_nostrconnect_url(template, runtime.signer_identity(), &public_relays) - }) - .transpose()?; - - Ok(Self { - app_identity, - signer_identity: runtime.signer_identity().clone(), - domain: discovery.domain.clone().ok_or_else(|| { - MycError::InvalidConfig( - "discovery.domain must be set when discovery.enabled is true".to_owned(), - ) - })?, - handler_identifier: discovery.handler_identifier.clone(), - public_relays, - publish_relays, - nostrconnect_url, - metadata: build_metadata(&discovery.metadata), - nip05_output_path: discovery.nip05_output_path.clone(), - connect_timeout_secs: runtime.config().transport.connect_timeout_secs, - }) - } - - pub fn app_identity(&self) -> &MycActiveIdentity { - &self.app_identity - } - - pub fn signer_identity(&self) -> &MycActiveIdentity { - &self.signer_identity - } - - pub fn domain(&self) -> &str { - self.domain.as_str() - } - - pub fn handler_identifier(&self) -> &str { - self.handler_identifier.as_str() - } - - pub fn publish_relays(&self) -> &[RadrootsNostrRelayUrl] { - self.publish_relays.as_slice() - } - - pub fn connect_timeout_secs(&self) -> u64 { - self.connect_timeout_secs - } - - pub fn nip05_output_path(&self) -> Option<&Path> { - self.nip05_output_path.as_deref() - } - - pub fn render_nip05_document(&self) -> MycNip05Document { - let mut names = BTreeMap::new(); - names.insert("_".to_owned(), self.app_identity.public_key_hex()); - MycNip05Document { - names, - nip46: MycNip05DocumentSection { - relays: self.public_relays.iter().map(ToString::to_string).collect(), - nostrconnect_url: self.nostrconnect_url.clone(), - }, - } - } - - pub fn render_nip05_json_pretty(&self) -> Result<String, MycError> { - Ok(serde_json::to_string_pretty(&self.render_nip05_document())?) - } - - pub fn render_nip05_output(&self, output_path: Option<PathBuf>) -> MycRenderedNip05Output { - MycRenderedNip05Output { - domain: self.domain.clone(), - output_path, - document: self.render_nip05_document(), - } - } - - pub fn write_nip05_document( - &self, - output_path: impl AsRef<Path>, - ) -> Result<MycRenderedNip05Output, MycError> { - let output_path = output_path.as_ref().to_path_buf(); - if let Some(parent) = output_path.parent() - && !parent.as_os_str().is_empty() - { - fs::create_dir_all(parent).map_err(|source| MycError::DiscoveryIo { - path: parent.to_path_buf(), - source, - })?; - } - let json = self.render_nip05_json_pretty()?; - fs::write(&output_path, json).map_err(|source| MycError::DiscoveryIo { - path: output_path.clone(), - source, - })?; - Ok(self.render_nip05_output(Some(output_path))) - } - - pub fn render_nip89_output(&self) -> Result<MycRenderedNip89Output, MycError> { - let event = self.build_signed_handler_event()?; - Ok(MycRenderedNip89Output { - author_public_key_hex: self.app_identity.public_key_hex(), - signer_public_key_hex: self.signer_identity.public_key_hex(), - publish_relays: self - .publish_relays - .iter() - .map(ToString::to_string) - .collect(), - event, - }) - } - - pub fn render_nip89_handler_document(&self) -> MycNip89HandlerDocument { - MycNip89HandlerDocument { - kinds: vec![NIP46_RPC_KIND], - identifier: self.handler_identifier.clone(), - relays: self.public_relays.iter().map(ToString::to_string).collect(), - nostrconnect_url: self.nostrconnect_url.clone(), - metadata: self.metadata.clone(), - } - } - - pub fn render_normalized_nip89_handler(&self) -> MycNormalizedNip89Handler { - MycNormalizedNip89Handler { - author_public_key_hex: self.app_identity.public_key_hex(), - kinds: vec![NIP46_RPC_KIND], - identifier: self.handler_identifier.clone(), - relays: normalize_string_list( - self.public_relays.iter().map(ToString::to_string).collect(), - ), - nostrconnect_url: normalize_optional_string(self.nostrconnect_url.clone()), - metadata: normalize_metadata(self.metadata.clone()), - } - } - - pub fn render_bundle_manifest(&self) -> MycDiscoveryBundleManifest { - MycDiscoveryBundleManifest { - version: DISCOVERY_BUNDLE_VERSION, - domain: self.domain.clone(), - author_public_key_hex: self.app_identity.public_key_hex(), - signer_public_key_hex: self.signer_identity.public_key_hex(), - public_relays: self.public_relays.iter().map(ToString::to_string).collect(), - publish_relays: self - .publish_relays - .iter() - .map(ToString::to_string) - .collect(), - nostrconnect_url: self.nostrconnect_url.clone(), - nip05_relative_path: DISCOVERY_BUNDLE_NIP05_RELATIVE_PATH.to_owned(), - nip89_relative_path: DISCOVERY_BUNDLE_NIP89_FILE_NAME.to_owned(), - } - } - - pub fn build_signed_handler_event(&self) -> Result<RadrootsNostrEvent, MycError> { - let builder = radroots_nostr_build_application_handler_event(&self.build_handler_spec())?; - self.app_identity - .sign_protocol_event_builder(builder, "NIP-89 application handler") - } - - pub fn write_bundle( - &self, - output_dir: impl AsRef<Path>, - ) -> Result<MycDiscoveryBundleOutput, MycError> { - let output_dir = output_dir.as_ref().to_path_buf(); - let staged_output_dir = prepare_staged_output_dir(&output_dir)?; - - let manifest = self.render_bundle_manifest(); - let nip05_document = self.render_nip05_document(); - let nip89_handler = self.render_nip89_handler_document(); - let manifest_path = staged_output_dir.join(DISCOVERY_BUNDLE_MANIFEST_FILE_NAME); - let nip05_path = staged_output_dir.join(DISCOVERY_BUNDLE_NIP05_RELATIVE_PATH); - let nip89_handler_path = staged_output_dir.join(DISCOVERY_BUNDLE_NIP89_FILE_NAME); - - write_pretty_json(&manifest_path, &manifest)?; - write_pretty_json(&nip05_path, &nip05_document)?; - write_pretty_json(&nip89_handler_path, &nip89_handler)?; - replace_directory_atomically(&staged_output_dir, &output_dir)?; - verify_bundle(&output_dir) - } - - fn build_handler_spec(&self) -> RadrootsNostrApplicationHandlerSpec { - let mut spec = RadrootsNostrApplicationHandlerSpec::new(vec![NIP46_RPC_KIND]) - .with_identifier(self.handler_identifier.clone()) - .with_relays(self.public_relays.iter().map(ToString::to_string).collect()); - if let Some(metadata) = self.metadata.clone() { - spec = spec.with_metadata(metadata); - } - if let Some(url) = self.nostrconnect_url.clone() { - spec = spec.with_nostr_connect_url(url); - } - spec - } -} - -pub fn render_nip05_output( - runtime: &MycRuntime, - output_path: Option<&Path>, -) -> Result<MycRenderedNip05Output, MycError> { - let context = MycDiscoveryContext::from_runtime(runtime)?; - match output_path { - Some(path) => context.write_nip05_document(path), - None => Ok(context.render_nip05_output(None)), - } -} - -pub async fn publish_nip89_event( - runtime: &MycRuntime, -) -> Result<MycPublishedNip89Output, MycError> { - let context = MycDiscoveryContext::from_runtime(runtime)?; - publish_nip89_event_to_relays(runtime, &context, context.publish_relays(), None).await -} - -async fn publish_nip89_event_to_relays( - runtime: &MycRuntime, - context: &MycDiscoveryContext, - relays: &[RadrootsNostrRelayUrl], - attempt_id: Option<&str>, -) -> Result<MycPublishedNip89Output, MycError> { - let event = context.build_signed_handler_event()?; - let event_id = event.id.to_hex(); - let outbox_record = - build_discovery_outbox_record(event.clone(), relays, event_id.as_str(), attempt_id)?; - if let Err(error) = runtime.delivery_outbox_store().enqueue(&outbox_record) { - record_discovery_publish_local_failure( - runtime, - relays.len(), - event_id.as_str(), - attempt_id, - error.to_string(), - ); - return Err(error); - } - let publish_outcome = match MycNostrTransport::publish_event_once( - context.app_identity(), - relays, - &runtime.config().transport, - "discovery handler publish", - &event, - ) - .await - { - Ok(outcome) => outcome, - Err(error) => { - let error = mark_discovery_outbox_publish_failed(runtime, &outbox_record, error); - record_discovery_publish_failure( - runtime, - relays.len(), - event_id.as_str(), - attempt_id, - &error, - ); - return Err(error); - } - }; - if let Err(error) = runtime - .delivery_outbox_store() - .mark_published_pending_finalize(&outbox_record.job_id, publish_outcome.attempt_count) - { - record_discovery_post_publish_failure( - runtime, - event_id.as_str(), - attempt_id, - &publish_outcome, - format!("failed to persist discovery outbox published state: {error}"), - ); - return Err(error); - } - if let Err(error) = runtime - .delivery_outbox_store() - .mark_finalized(&outbox_record.job_id) - { - record_discovery_post_publish_failure( - runtime, - event_id.as_str(), - attempt_id, - &publish_outcome, - format!("failed to finalize discovery outbox job: {error}"), - ); - return Err(error); - } - - record_discovery_publish_success(runtime, event_id.as_str(), attempt_id, &publish_outcome); - - Ok(MycPublishedNip89Output { - author_public_key_hex: context.app_identity().public_key_hex(), - signer_public_key_hex: context.signer_identity().public_key_hex(), - publish_relays: relays.iter().map(ToString::to_string).collect(), - relay_count: publish_outcome.relay_count, - acknowledged_relay_count: publish_outcome.acknowledged_relay_count, - relay_outcome_summary: publish_outcome.relay_outcome_summary, - relay_results: publish_outcome.relay_results, - event, - }) -} - -pub async fn fetch_live_nip89(runtime: &MycRuntime) -> Result<MycFetchedLiveNip89Output, MycError> { - let context = MycDiscoveryContext::from_runtime(runtime)?; - let fetched = fetch_live_nip89_state_for_runtime(runtime, &context, None).await?; - Ok(MycFetchedLiveNip89Output { - author_public_key_hex: context.app_identity().public_key_hex(), - publish_relays: context - .publish_relays() - .iter() - .map(ToString::to_string) - .collect(), - handler_identifier: context.handler_identifier().to_owned(), - live_groups: fetched.live_groups, - relay_states: fetched.relay_states, - }) -} - -pub async fn diff_live_nip89(runtime: &MycRuntime) -> Result<MycDiscoveryDiffOutput, MycError> { - let context = MycDiscoveryContext::from_runtime(runtime)?; - let local_handler = context.render_normalized_nip89_handler(); - let fetched = fetch_live_nip89_state_for_runtime(runtime, &context, None).await?; - let relay_states = build_relay_diffs(&local_handler, &fetched.relay_states); - let relay_summary = summarize_relay_diffs(&relay_states); - let live_groups = fetched.live_groups; - let (status, differing_fields) = compare_live_handler(&local_handler, &live_groups); - Ok(MycDiscoveryDiffOutput { - status, - local_handler, - live_groups, - relay_states, - relay_summary, - differing_fields, - }) -} - -pub async fn refresh_nip89( - runtime: &MycRuntime, - force: bool, -) -> Result<MycRefreshedNip89Output, MycError> { - let context = MycDiscoveryContext::from_runtime(runtime)?; - let attempt_id = RadrootsNostrSignerRequestId::new_v7().into_string(); - let configured_publish_relays = relay_urls_to_strings(context.publish_relays()); - let local_handler = context.render_normalized_nip89_handler(); - let fetched = match fetch_live_nip89_state_for_runtime( - runtime, - &context, - Some(attempt_id.as_str()), - ) - .await - { - Ok(fetched) => fetched, - Err(MycError::DiscoveryFetchUnavailable { - relay_count, - details, - }) => { - runtime.record_operation_audit( - &MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerRefresh, - MycOperationAuditOutcome::Unavailable, - None, - None, - relay_count, - 0, - details.clone(), - ) - .with_attempt_id(attempt_id.clone()) - .with_blocked_relays("all_relays_unavailable", configured_publish_relays.clone()), - ); - return Err(MycError::DiscoveryFetchUnavailable { - relay_count, - details, - } - .with_discovery_refresh_attempt_id(attempt_id)); - } - Err(error) => { - return Err(error.with_discovery_refresh_attempt_id(attempt_id)); - } - }; - let relay_states = build_relay_diffs(&local_handler, &fetched.relay_states); - let relay_summary = summarize_relay_diffs(&relay_states); - let live_groups = fetched.live_groups; - let (status, differing_fields) = compare_live_handler(&local_handler, &live_groups); - let relay_count = context.publish_relays().len(); - let compare_request_id = latest_live_event_id(&live_groups); - let compare_summary = - describe_compare_status(status, &differing_fields, &live_groups, &relay_summary); - let blocked_refresh_plan = build_refresh_plan(&context, &relay_states, true) - .map_err(|error| error.with_discovery_refresh_attempt_id(attempt_id.clone()))?; - - runtime.record_operation_audit( - &MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerCompare, - compare_status_to_audit_outcome(status), - None, - compare_request_id, - relay_count, - relay_count.saturating_sub(relay_summary.unavailable_relays.len()), - compare_summary, - ) - .with_attempt_id(attempt_id.clone()), - ); - - if !relay_summary.unavailable_relays.is_empty() && !force { - runtime.record_operation_audit( - &MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerRefresh, - MycOperationAuditOutcome::Unavailable, - None, - compare_request_id, - relay_count, - relay_count.saturating_sub(relay_summary.unavailable_relays.len()), - format!( - "discovery relays were unavailable; rerun refresh with --force to override: {}", - relay_summary.unavailable_relays.join(", ") - ), - ) - .with_attempt_id(attempt_id.clone()) - .with_planned_repair_relays(blocked_refresh_plan.planned_repair_relays.clone()) - .with_blocked_relays( - "unavailable_relays", - relay_summary.unavailable_relays.clone(), - ), - ); - return Err( - MycError::InvalidOperation(format!( - "one or more discovery relays were unavailable; rerun `discovery refresh-nip89 --force` to override: {}", - relay_summary.unavailable_relays.join(", ") - )) - .with_discovery_refresh_attempt_id(attempt_id), - ); - } - - if !relay_summary.conflicted_relays.is_empty() && !force { - runtime.record_operation_audit( - &MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerRefresh, - MycOperationAuditOutcome::Conflicted, - None, - compare_request_id, - relay_count, - relay_count.saturating_sub(relay_summary.unavailable_relays.len()), - "live discovery handler state is conflicted; rerun refresh with --force to override" - .to_owned(), - ) - .with_attempt_id(attempt_id.clone()) - .with_planned_repair_relays(blocked_refresh_plan.planned_repair_relays.clone()) - .with_blocked_relays( - "conflicted_relays", - relay_summary.conflicted_relays.clone(), - ), - ); - return Err( - MycError::InvalidOperation( - "live discovery handler state is conflicted; rerun `discovery refresh-nip89 --force` to override" - .to_owned(), - ) - .with_discovery_refresh_attempt_id(attempt_id), - ); - } - - let refresh_plan = build_refresh_plan(&context, &relay_states, force) - .map_err(|error| error.with_discovery_refresh_attempt_id(attempt_id.clone()))?; - let refresh_relays = refresh_plan.selected_relays; - let refresh_relay_urls = relay_urls_to_strings(&refresh_relays); - - if refresh_relays.is_empty() { - let repair_results = build_repair_results(&context, &relay_states, &[], None, None); - let repair_summary = summarize_repair_results(&repair_results); - record_refresh_repair_audit( - runtime, - compare_request_id.map(ToOwned::to_owned), - attempt_id.as_str(), - &repair_results, - ); - runtime.record_operation_audit( - &MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerRefresh, - MycOperationAuditOutcome::Skipped, - None, - compare_request_id, - relay_count, - relay_count.saturating_sub(relay_summary.unavailable_relays.len()), - "local discovery handler already matches live state".to_owned(), - ) - .with_attempt_id(attempt_id.clone()) - .with_planned_repair_relays(refresh_relay_urls.clone()), - ); - return Ok(MycRefreshedNip89Output { - attempt_id, - status, - force, - differing_fields, - live_groups, - relay_states, - relay_summary, - repair_summary, - repair_results, - remaining_repair_relays: Vec::new(), - published: None, - }); - } - - match publish_nip89_event_to_relays( - runtime, - &context, - &refresh_relays, - Some(attempt_id.as_str()), - ) - .await - { - Ok(published) => { - let published_event_id = published.event.id.to_hex(); - let repair_results = build_repair_results( - &context, - &relay_states, - &refresh_relays, - Some(published.relay_results.as_slice()), - None, - ); - record_refresh_repair_audit( - runtime, - Some(published_event_id.clone()), - attempt_id.as_str(), - &repair_results, - ); - let repair_summary = summarize_repair_results(&repair_results); - let remaining_repair_relays = remaining_repair_relays(&repair_results); - runtime.record_operation_audit( - &MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerRefresh, - MycOperationAuditOutcome::Succeeded, - None, - Some(published_event_id.as_str()), - published.relay_count, - published.acknowledged_relay_count, - format!( - "refresh completed with {} repaired, {} failed, {} unchanged, {} skipped", - repair_summary.repaired, - repair_summary.failed, - repair_summary.unchanged, - repair_summary.skipped - ), - ) - .with_attempt_id(attempt_id.clone()) - .with_planned_repair_relays(refresh_relay_urls.clone()), - ); - Ok(MycRefreshedNip89Output { - attempt_id, - status, - force, - differing_fields, - live_groups, - relay_states, - relay_summary, - repair_summary, - repair_results, - remaining_repair_relays, - published: Some(published), - }) - } - Err(error) => { - let repair_results = - build_repair_results(&context, &relay_states, &refresh_relays, None, Some(&error)); - let repair_summary = summarize_repair_results(&repair_results); - record_refresh_repair_audit(runtime, None, attempt_id.as_str(), &repair_results); - runtime.record_operation_audit( - &MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerRefresh, - MycOperationAuditOutcome::Rejected, - None, - compare_request_id, - relay_count, - relay_states - .iter() - .filter(|relay_state| { - relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Available - }) - .count(), - format!( - "refresh failed with {} repaired, {} failed, {} unchanged, {} skipped", - repair_summary.repaired, - repair_summary.failed, - repair_summary.unchanged, - repair_summary.skipped - ), - ) - .with_attempt_id(attempt_id.clone()) - .with_planned_repair_relays(refresh_relay_urls.clone()), - ); - Err(error.with_discovery_refresh_attempt_id(attempt_id)) - } - } -} - -fn build_discovery_outbox_record( - event: RadrootsNostrEvent, - relays: &[RadrootsNostrRelayUrl], - event_id: &str, - attempt_id: Option<&str>, -) -> Result<MycDeliveryOutboxRecord, MycError> { - let mut record = MycDeliveryOutboxRecord::new( - MycDeliveryOutboxKind::DiscoveryHandlerPublish, - event, - relays.to_vec(), - )? - .with_request_id(event_id.to_owned()); - if let Some(attempt_id) = attempt_id { - record = record.with_attempt_id(attempt_id.to_owned()); - } - Ok(record) -} - -fn mark_discovery_outbox_publish_failed( - runtime: &MycRuntime, - outbox_record: &MycDeliveryOutboxRecord, - error: MycError, -) -> MycError { - let publish_attempt_count = error.publish_attempt_count().unwrap_or_default(); - let summary = publish_failure_summary(&error); - match runtime.delivery_outbox_store().mark_failed( - &outbox_record.job_id, - publish_attempt_count, - &summary, - ) { - Ok(_) => error, - Err(outbox_error) => MycError::InvalidOperation(format!( - "{error}; additionally failed to persist discovery publish failure to the outbox: {outbox_error}" - )), - } -} - -fn record_discovery_publish_local_failure( - runtime: &MycRuntime, - relay_count: usize, - event_id: &str, - attempt_id: Option<&str>, - summary: impl Into<String>, -) { - let mut record = MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerPublish, - MycOperationAuditOutcome::Rejected, - None, - Some(event_id), - relay_count, - 0, - summary.into(), - ); - if let Some(attempt_id) = attempt_id { - record = record.with_attempt_id(attempt_id); - } - runtime.record_operation_audit(&record); -} - -fn record_discovery_publish_failure( - runtime: &MycRuntime, - relay_count: usize, - event_id: &str, - attempt_id: Option<&str>, - error: &MycError, -) { - let mut record = MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerPublish, - MycOperationAuditOutcome::Rejected, - None, - Some(event_id), - error - .publish_rejection_counts() - .map(|(publish_relay_count, _)| publish_relay_count) - .unwrap_or(relay_count), - error - .publish_rejection_counts() - .map(|(_, acknowledged)| acknowledged) - .unwrap_or_default(), - publish_failure_summary(error), - ); - if let (Some(delivery_policy), Some(required_acknowledged_relay_count), Some(attempt_count)) = ( - error.publish_delivery_policy(), - error.publish_required_acknowledged_relay_count(), - error.publish_attempt_count(), - ) { - record = record.with_delivery_details( - delivery_policy, - required_acknowledged_relay_count, - attempt_count, - ); - } - if let Some(attempt_id) = attempt_id { - record = record.with_attempt_id(attempt_id); - } - runtime.record_operation_audit(&record); -} - -fn record_discovery_post_publish_failure( - runtime: &MycRuntime, - event_id: &str, - attempt_id: Option<&str>, - publish_outcome: &MycPublishOutcome, - summary: impl Into<String>, -) { - let mut record = MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerPublish, - MycOperationAuditOutcome::Rejected, - None, - Some(event_id), - publish_outcome.relay_count, - publish_outcome.acknowledged_relay_count, - summary.into(), - ) - .with_delivery_details( - publish_outcome.delivery_policy, - publish_outcome.required_acknowledged_relay_count, - publish_outcome.attempt_count, - ); - if let Some(attempt_id) = attempt_id { - record = record.with_attempt_id(attempt_id); - } - runtime.record_operation_audit(&record); -} - -fn record_discovery_publish_success( - runtime: &MycRuntime, - event_id: &str, - attempt_id: Option<&str>, - publish_outcome: &MycPublishOutcome, -) { - let mut record = MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerPublish, - MycOperationAuditOutcome::Succeeded, - None, - Some(event_id), - publish_outcome.relay_count, - publish_outcome.acknowledged_relay_count, - publish_outcome.relay_outcome_summary.clone(), - ) - .with_delivery_details( - publish_outcome.delivery_policy, - publish_outcome.required_acknowledged_relay_count, - publish_outcome.attempt_count, - ); - if let Some(attempt_id) = attempt_id { - record = record.with_attempt_id(attempt_id); - } - runtime.record_operation_audit(&record); -} - -fn publish_failure_summary(error: &MycError) -> String { - error - .publish_rejection_details() - .map(ToOwned::to_owned) - .unwrap_or_else(|| error.to_string()) -} - -fn build_refresh_plan( - context: &MycDiscoveryContext, - relay_states: &[MycDiscoveryRelayState], - force: bool, -) -> Result<MycDiscoveryRefreshPlan, MycError> { - let selected_relays = select_refresh_relays(context, relay_states, force)?; - Ok(MycDiscoveryRefreshPlan { - selected_relays: selected_relays.clone(), - planned_repair_relays: relay_urls_to_strings(&selected_relays), - }) -} - -fn relay_urls_to_strings(relays: &[RadrootsNostrRelayUrl]) -> Vec<String> { - relays.iter().map(ToString::to_string).collect() -} - -fn select_refresh_relays( - context: &MycDiscoveryContext, - relay_states: &[MycDiscoveryRelayState], - force: bool, -) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> { - if context.publish_relays().len() != relay_states.len() { - return Err(MycError::InvalidOperation( - "discovery relay state count did not match configured publish relay count".to_owned(), - )); - } - - let mut repair_relays = Vec::new(); - let mut matched_relays = Vec::new(); - - for (relay, relay_state) in context.publish_relays().iter().zip(relay_states.iter()) { - if relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable { - continue; - } - - match relay_state.live_status { - Some(MycDiscoveryLiveStatus::Missing | MycDiscoveryLiveStatus::Drifted) => { - repair_relays.push(relay.clone()); - } - Some(MycDiscoveryLiveStatus::Conflicted) => { - if force { - repair_relays.push(relay.clone()); - } - } - Some(MycDiscoveryLiveStatus::Matched) => { - matched_relays.push(relay.clone()); - } - None => {} - } - } - - if repair_relays.is_empty() && force { - Ok(matched_relays) - } else { - Ok(repair_relays) - } -} - -fn build_repair_results( - context: &MycDiscoveryContext, - relay_states: &[MycDiscoveryRelayState], - refresh_relays: &[RadrootsNostrRelayUrl], - publish_results: Option<&[MycRelayPublishResult]>, - publish_error: Option<&MycError>, -) -> Vec<MycDiscoveryRelayRepairResult> { - let selected_relays = refresh_relays - .iter() - .map(ToString::to_string) - .collect::<BTreeSet<_>>(); - let publish_results_by_relay = publish_results - .unwrap_or_default() - .iter() - .map(|result| (result.relay_url.clone(), result)) - .collect::<BTreeMap<_, _>>(); - let rejected_relays = publish_error - .and_then(MycError::publish_rejected_relays) - .unwrap_or_default() - .iter() - .cloned() - .collect::<BTreeSet<_>>(); - - context - .publish_relays() - .iter() - .zip(relay_states.iter()) - .map(|(relay, relay_state)| { - let relay_url = relay.to_string(); - if selected_relays.contains(&relay_url) { - if let Some(result) = publish_results_by_relay.get(&relay_url) { - return MycDiscoveryRelayRepairResult { - relay_url, - outcome: if result.acknowledged { - MycDiscoveryRepairOutcome::Repaired - } else { - MycDiscoveryRepairOutcome::Failed - }, - detail: result.detail.clone(), - }; - } - - if rejected_relays.contains(&relay_url) { - return MycDiscoveryRelayRepairResult { - relay_url, - outcome: MycDiscoveryRepairOutcome::Failed, - detail: Some( - publish_error - .and_then(MycError::publish_rejection_details) - .map(ToOwned::to_owned) - .unwrap_or_else(|| "targeted refresh publish failed".to_owned()), - ), - }; - } - - return MycDiscoveryRelayRepairResult { - relay_url, - outcome: MycDiscoveryRepairOutcome::Failed, - detail: Some("no relay publish result was reported".to_owned()), - }; - } - - if relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable { - return MycDiscoveryRelayRepairResult { - relay_url, - outcome: MycDiscoveryRepairOutcome::Skipped, - detail: relay_state.fetch_error.clone(), - }; - } - - match relay_state.live_status { - Some(MycDiscoveryLiveStatus::Matched) => MycDiscoveryRelayRepairResult { - relay_url, - outcome: MycDiscoveryRepairOutcome::Unchanged, - detail: None, - }, - _ => MycDiscoveryRelayRepairResult { - relay_url, - outcome: MycDiscoveryRepairOutcome::Skipped, - detail: None, - }, - } - }) - .collect() -} - -fn remaining_repair_relays(repair_results: &[MycDiscoveryRelayRepairResult]) -> Vec<String> { - repair_results - .iter() - .filter(|result| result.outcome == MycDiscoveryRepairOutcome::Failed) - .map(|result| result.relay_url.clone()) - .collect() -} - -fn summarize_repair_results( - repair_results: &[MycDiscoveryRelayRepairResult], -) -> MycDiscoveryRepairSummary { - let mut summary = MycDiscoveryRepairSummary::default(); - for result in repair_results { - match result.outcome { - MycDiscoveryRepairOutcome::Repaired => summary.repaired += 1, - MycDiscoveryRepairOutcome::Failed => summary.failed += 1, - MycDiscoveryRepairOutcome::Unchanged => summary.unchanged += 1, - MycDiscoveryRepairOutcome::Skipped => summary.skipped += 1, - } - } - summary -} - -fn record_refresh_repair_audit( - runtime: &MycRuntime, - request_id: Option<String>, - attempt_id: &str, - repair_results: &[MycDiscoveryRelayRepairResult], -) { - for result in repair_results { - let (outcome, acknowledged_relay_count) = match result.outcome { - MycDiscoveryRepairOutcome::Repaired => (MycOperationAuditOutcome::Succeeded, 1), - MycDiscoveryRepairOutcome::Failed => (MycOperationAuditOutcome::Rejected, 0), - MycDiscoveryRepairOutcome::Unchanged => (MycOperationAuditOutcome::Matched, 0), - MycDiscoveryRepairOutcome::Skipped => (MycOperationAuditOutcome::Skipped, 0), - }; - - runtime.record_operation_audit( - &MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerRepair, - outcome, - None, - request_id.as_deref(), - 1, - acknowledged_relay_count, - result - .detail - .clone() - .unwrap_or_else(|| result.relay_url.clone()), - ) - .with_attempt_id(attempt_id) - .with_relay_url(result.relay_url.clone()), - ); - } -} - -async fn fetch_live_nip89_state_for_runtime( - runtime: &MycRuntime, - context: &MycDiscoveryContext, - attempt_id: Option<&str>, -) -> Result<MycFetchedLiveNip89State, MycError> { - match fetch_live_nip89_state(context).await { - Ok(fetched) => { - let unavailable_relays = fetched - .relay_states - .iter() - .filter(|relay_state| { - relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable - }) - .collect::<Vec<_>>(); - if !unavailable_relays.is_empty() { - let mut record = MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerFetch, - MycOperationAuditOutcome::Unavailable, - None, - latest_live_event_id(&fetched.live_groups), - fetched.relay_states.len(), - fetched.relay_states.len() - unavailable_relays.len(), - summarize_unavailable_relays(&fetched.relay_states), - ); - if let Some(attempt_id) = attempt_id { - record = record.with_attempt_id(attempt_id); - } - runtime.record_operation_audit(&record); - } - Ok(fetched) - } - Err(MycError::DiscoveryFetchUnavailable { - relay_count, - details, - }) => { - let mut record = MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerFetch, - MycOperationAuditOutcome::Unavailable, - None, - None, - relay_count, - 0, - details.clone(), - ); - if let Some(attempt_id) = attempt_id { - record = record.with_attempt_id(attempt_id); - } - runtime.record_operation_audit(&record); - Err(MycError::DiscoveryFetchUnavailable { - relay_count, - details, - }) - } - Err(error) => Err(error), - } -} - -pub fn verify_bundle(output_dir: impl AsRef<Path>) -> Result<MycDiscoveryBundleOutput, MycError> { - let output_dir = output_dir.as_ref().to_path_buf(); - let manifest_path = output_dir.join(DISCOVERY_BUNDLE_MANIFEST_FILE_NAME); - let manifest = read_json_file::<MycDiscoveryBundleManifest>(&manifest_path)?; - let nip05_path = output_dir.join(&manifest.nip05_relative_path); - let nip05_document = read_json_file::<MycNip05Document>(&nip05_path)?; - let nip89_handler_path = output_dir.join(&manifest.nip89_relative_path); - let nip89_handler = read_json_file::<MycNip89HandlerDocument>(&nip89_handler_path)?; - - let bundle = MycDiscoveryBundleOutput { - output_dir, - manifest_path, - nip05_path, - nip89_handler_path, - manifest, - nip05_document, - nip89_handler, - }; - bundle.validate()?; - Ok(bundle) -} - -async fn fetch_live_nip89_state( - context: &MycDiscoveryContext, -) -> Result<MycFetchedLiveNip89State, MycError> { - let relay_count = context.publish_relays().len(); - let mut pending = context - .publish_relays() - .iter() - .cloned() - .enumerate() - .collect::<Vec<_>>() - .into_iter(); - let mut join_set = JoinSet::new(); - let max_concurrency = relay_count.min(DISCOVERY_RELAY_FETCH_CONCURRENCY_LIMIT); - - while join_set.len() < max_concurrency { - let Some((relay_index, relay)) = pending.next() else { - break; - }; - spawn_live_nip89_relay_fetch(&mut join_set, context.clone(), relay_index, relay); - } - - let mut fetched = std::iter::repeat_with(|| None) - .take(relay_count) - .collect::<Vec<Option<MycRelayFetchTaskOutput>>>(); - - while let Some(joined) = join_set.join_next().await { - let output = joined.map_err(|error| { - MycError::InvalidOperation(format!("discovery relay fetch task failed: {error}")) - })??; - let relay_index = output.relay_index; - fetched[relay_index] = Some(output); - - while join_set.len() < max_concurrency { - let Some((relay_index, relay)) = pending.next() else { - break; - }; - spawn_live_nip89_relay_fetch(&mut join_set, context.clone(), relay_index, relay); - } - } - - let mut relay_states = Vec::with_capacity(relay_count); - let mut all_events = Vec::new(); - for fetched_relay in fetched { - let fetched_relay = fetched_relay.ok_or_else(|| { - MycError::InvalidOperation("missing discovery relay fetch result".to_owned()) - })?; - all_events.extend(fetched_relay.relay_events); - relay_states.push(fetched_relay.relay_state); - } - - let available_relay_count = relay_states - .iter() - .filter(|relay_state| relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Available) - .count(); - if available_relay_count == 0 { - return Err(MycError::DiscoveryFetchUnavailable { - relay_count: relay_states.len(), - details: summarize_unavailable_relays(&relay_states), - }); - } - - Ok(MycFetchedLiveNip89State { - live_groups: group_live_nip89_events(all_events)?, - relay_states, - }) -} - -fn spawn_live_nip89_relay_fetch( - join_set: &mut JoinSet<Result<MycRelayFetchTaskOutput, MycError>>, - context: MycDiscoveryContext, - relay_index: usize, - relay: RadrootsNostrRelayUrl, -) { - join_set.spawn(async move { fetch_live_nip89_relay_state(&context, relay_index, relay).await }); -} - -async fn fetch_live_nip89_relay_state( - context: &MycDiscoveryContext, - relay_index: usize, - relay: RadrootsNostrRelayUrl, -) -> Result<MycRelayFetchTaskOutput, MycError> { - let relay_url = relay.to_string(); - match fetch_live_nip89_events_for_relay(context, &relay).await { - Ok(relay_events) => { - let live_groups = group_live_nip89_events(relay_events.clone())?; - Ok(MycRelayFetchTaskOutput { - relay_index, - relay_events, - relay_state: MycLiveNip89RelayState { - relay_url, - fetch_status: MycDiscoveryRelayFetchStatus::Available, - fetch_error: None, - live_groups, - }, - }) - } - Err(error) => Ok(MycRelayFetchTaskOutput { - relay_index, - relay_events: Vec::new(), - relay_state: MycLiveNip89RelayState { - relay_url, - fetch_status: MycDiscoveryRelayFetchStatus::Unavailable, - fetch_error: Some(error.to_string()), - live_groups: Vec::new(), - }, - }), - } -} - -async fn fetch_live_nip89_events_for_relay( - context: &MycDiscoveryContext, - relay: &RadrootsNostrRelayUrl, -) -> Result<Vec<MycSourcedLiveNip89Event>, MycError> { - let client = context.app_identity().nostr_client(); - let _ = client.add_relay(relay.as_str()).await?; - client - .clone() - .into_inner() - .try_connect_relay( - relay.as_str(), - Duration::from_secs(context.connect_timeout_secs()), - ) - .await - .map_err(MycError::from)?; - - let mut filter = RadrootsNostrFilter::new() - .author(context.app_identity().public_key()) - .kind(RadrootsNostrKind::Custom(31_990)); - filter = radroots_nostr_filter_tag(filter, "d", vec![context.handler_identifier().to_owned()])?; - filter = radroots_nostr_filter_tag(filter, "k", vec![NIP46_RPC_KIND.to_string()])?; - - let mut events = client - .fetch_events(filter, Duration::from_secs(context.connect_timeout_secs())) - .await?; - events.sort_by(|left, right| { - left.created_at - .as_secs() - .cmp(&right.created_at.as_secs()) - .then_with(|| left.id.to_hex().cmp(&right.id.to_hex())) - }); - Ok(events - .into_iter() - .map(|event| MycSourcedLiveNip89Event { - source_relay: relay.to_string(), - event, - }) - .collect()) -} - -fn compare_live_handler( - local_handler: &MycNormalizedNip89Handler, - live_groups: &[MycLiveNip89Group], -) -> (MycDiscoveryLiveStatus, Vec<String>) { - if live_groups.is_empty() { - return ( - MycDiscoveryLiveStatus::Missing, - vec!["live_groups".to_owned()], - ); - } - if live_groups.len() > 1 { - return ( - MycDiscoveryLiveStatus::Conflicted, - vec!["live_groups".to_owned()], - ); - } - - let live_group = &live_groups[0]; - - let mut differing_fields = Vec::new(); - if live_group.handler.author_public_key_hex != local_handler.author_public_key_hex { - differing_fields.push("author_public_key_hex".to_owned()); - } - if live_group.handler.kinds != local_handler.kinds { - differing_fields.push("kinds".to_owned()); - } - if live_group.handler.identifier != local_handler.identifier { - differing_fields.push("identifier".to_owned()); - } - if live_group.handler.relays != local_handler.relays { - differing_fields.push("relays".to_owned()); - } - if live_group.handler.nostrconnect_url != local_handler.nostrconnect_url { - differing_fields.push("nostrconnect_url".to_owned()); - } - if live_group.handler.metadata != local_handler.metadata { - differing_fields.push("metadata".to_owned()); - } - - if differing_fields.is_empty() { - (MycDiscoveryLiveStatus::Matched, differing_fields) - } else { - (MycDiscoveryLiveStatus::Drifted, differing_fields) - } -} - -fn compare_status_to_audit_outcome(status: MycDiscoveryLiveStatus) -> MycOperationAuditOutcome { - match status { - MycDiscoveryLiveStatus::Missing => MycOperationAuditOutcome::Missing, - MycDiscoveryLiveStatus::Matched => MycOperationAuditOutcome::Matched, - MycDiscoveryLiveStatus::Drifted => MycOperationAuditOutcome::Drifted, - MycDiscoveryLiveStatus::Conflicted => MycOperationAuditOutcome::Conflicted, - } -} - -fn describe_compare_status( - status: MycDiscoveryLiveStatus, - differing_fields: &[String], - live_groups: &[MycLiveNip89Group], - relay_summary: &MycDiscoveryRelaySummary, -) -> String { - let base = match status { - MycDiscoveryLiveStatus::Missing => { - "no live NIP-89 handler was found for the configured discovery identity".to_owned() - } - MycDiscoveryLiveStatus::Matched => { - "local discovery handler matches the latest live NIP-89 handler".to_owned() - } - MycDiscoveryLiveStatus::Drifted => format!( - "local discovery handler differs from live state in: {}", - differing_fields.join(", ") - ), - MycDiscoveryLiveStatus::Conflicted => format!( - "found {} conflicting live NIP-89 handler states across {} events (matched relays: {}, drifted relays: {}, missing relays: {}, conflicted relays: {})", - live_groups.len(), - live_groups - .iter() - .map(|group| group.events.len()) - .sum::<usize>(), - relay_summary.matched_relays.len(), - relay_summary.drifted_relays.len(), - relay_summary.missing_relays.len(), - relay_summary.conflicted_relays.len(), - ), - }; - - if relay_summary.unavailable_relays.is_empty() { - base - } else { - format!( - "{base}; unavailable relays: {}", - relay_summary.unavailable_relays.join(", ") - ) - } -} - -fn normalize_live_nip89_handler( - event: &RadrootsNostrEvent, -) -> Result<MycNormalizedNip89Handler, MycError> { - if event.kind != RadrootsNostrKind::Custom(31_990) { - return Err(MycError::InvalidDiscoveryEvent(format!( - "expected kind 31990 but found kind {}", - event.kind.as_u16() - ))); - } - - let identifier = event - .tags - .iter() - .find_map(|tag| radroots_nostr_tag_first_value(tag, "d")) - .map(|value| value.trim().to_owned()) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - MycError::InvalidDiscoveryEvent( - "live handler event is missing a non-empty `d` tag".to_owned(), - ) - })?; - - let mut kinds = event - .tags - .iter() - .filter_map(|tag| radroots_nostr_tag_first_value(tag, "k")) - .map(|value| { - value.parse::<u32>().map_err(|error| { - MycError::InvalidDiscoveryEvent(format!( - "failed to parse live handler kind `{value}`: {error}" - )) - }) - }) - .collect::<Result<Vec<_>, _>>()?; - if kinds.is_empty() { - return Err(MycError::InvalidDiscoveryEvent( - "live handler event is missing `k` tags".to_owned(), - )); - } - kinds.sort_unstable(); - kinds.dedup(); - - let relays = normalize_string_list( - event - .tags - .iter() - .filter_map(|tag| radroots_nostr_tag_first_value(tag, "relay")) - .collect(), - ); - let nostrconnect_url = normalize_optional_string( - event - .tags - .iter() - .find_map(|tag| radroots_nostr_tag_first_value(tag, "nostrconnect_url")), - ); - let metadata = if event.content.trim().is_empty() { - None - } else { - Some( - serde_json::from_str::<RadrootsNostrMetadata>(&event.content).map_err(|error| { - MycError::InvalidDiscoveryEvent(format!( - "failed to parse live handler metadata: {error}" - )) - })?, - ) - }; - - Ok(MycNormalizedNip89Handler { - author_public_key_hex: event.pubkey.to_hex(), - kinds, - identifier, - relays, - nostrconnect_url, - metadata: normalize_metadata(metadata), - }) -} - -fn group_live_nip89_events( - events: Vec<MycSourcedLiveNip89Event>, -) -> Result<Vec<MycLiveNip89Group>, MycError> { - let mut groups = Vec::<MycLiveNip89Group>::new(); - for sourced_event in events { - let handler = normalize_live_nip89_handler(&sourced_event.event)?; - let source_relay = sourced_event.source_relay; - let live_event = MycLiveNip89Event { - event_id_hex: sourced_event.event.id.to_hex(), - created_at_unix: sourced_event.event.created_at.as_secs(), - source_relays: vec![source_relay.clone()], - handler: handler.clone(), - }; - if let Some(existing_group) = groups.iter_mut().find(|group| group.handler == handler) { - if let Some(existing_event) = existing_group - .events - .iter_mut() - .find(|event| event.event_id_hex == live_event.event_id_hex) - { - existing_event.source_relays = normalize_string_list( - existing_event - .source_relays - .iter() - .cloned() - .chain(std::iter::once(source_relay.clone())) - .collect(), - ); - } else { - existing_group.events.push(live_event); - } - existing_group.source_relays = normalize_string_list( - existing_group - .source_relays - .iter() - .cloned() - .chain(std::iter::once(source_relay)) - .collect(), - ); - } else { - groups.push(MycLiveNip89Group { - handler: handler.clone(), - source_relays: vec![source_relay], - events: vec![live_event], - }); - } - } - - for group in &mut groups { - group.source_relays = normalize_string_list(group.source_relays.clone()); - group.events.sort_by(|left, right| { - left.created_at_unix - .cmp(&right.created_at_unix) - .then_with(|| left.event_id_hex.cmp(&right.event_id_hex)) - }); - for event in &mut group.events { - event.source_relays = normalize_string_list(event.source_relays.clone()); - } - } - - groups.sort_by(|left, right| { - latest_group_sort_key(right) - .cmp(&latest_group_sort_key(left)) - .then_with(|| left.handler.identifier.cmp(&right.handler.identifier)) - .then_with(|| { - left.handler - .author_public_key_hex - .cmp(&right.handler.author_public_key_hex) - }) - }); - - Ok(groups) -} - -fn build_relay_diffs( - local_handler: &MycNormalizedNip89Handler, - relay_states: &[MycLiveNip89RelayState], -) -> Vec<MycDiscoveryRelayState> { - relay_states - .iter() - .map(|relay_state| { - let (live_status, differing_fields) = - if relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable { - (None, Vec::new()) - } else { - let (status, differing_fields) = - compare_live_handler(local_handler, &relay_state.live_groups); - (Some(status), differing_fields) - }; - MycDiscoveryRelayState { - relay_url: relay_state.relay_url.clone(), - fetch_status: relay_state.fetch_status, - fetch_error: relay_state.fetch_error.clone(), - live_status, - differing_fields, - live_groups: relay_state.live_groups.clone(), - } - }) - .collect() -} - -fn summarize_relay_diffs(relay_states: &[MycDiscoveryRelayState]) -> MycDiscoveryRelaySummary { - let mut summary = MycDiscoveryRelaySummary { - total_relays: relay_states.len(), - ..MycDiscoveryRelaySummary::default() - }; - - for relay_state in relay_states { - if relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable { - summary - .unavailable_relays - .push(relay_state.relay_url.clone()); - continue; - } - match relay_state.live_status { - Some(MycDiscoveryLiveStatus::Missing) => { - summary.missing_relays.push(relay_state.relay_url.clone()) - } - Some(MycDiscoveryLiveStatus::Matched) => { - summary.matched_relays.push(relay_state.relay_url.clone()) - } - Some(MycDiscoveryLiveStatus::Drifted) => { - summary.drifted_relays.push(relay_state.relay_url.clone()) - } - Some(MycDiscoveryLiveStatus::Conflicted) => summary - .conflicted_relays - .push(relay_state.relay_url.clone()), - None => {} - } - } - - summary -} - -fn summarize_unavailable_relays(relay_states: &[MycLiveNip89RelayState]) -> String { - let unavailable = relay_states - .iter() - .filter(|relay_state| relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable) - .map(|relay_state| { - let details = relay_state - .fetch_error - .as_deref() - .unwrap_or("unknown relay fetch failure"); - format!("{}: {details}", relay_state.relay_url) - }) - .collect::<Vec<_>>(); - - if unavailable.is_empty() { - "all configured discovery relays were available".to_owned() - } else { - format!("unavailable discovery relays: {}", unavailable.join("; ")) - } -} - -fn latest_group_sort_key(group: &MycLiveNip89Group) -> (u64, &str) { - group - .events - .last() - .map(|event| (event.created_at_unix, event.event_id_hex.as_str())) - .unwrap_or((0, "")) -} - -fn latest_live_event_id(live_groups: &[MycLiveNip89Group]) -> Option<&str> { - live_groups - .first() - .and_then(|group| group.events.last()) - .map(|event| event.event_id_hex.as_str()) -} - -fn build_metadata(config: &MycDiscoveryMetadataConfig) -> Option<RadrootsNostrMetadata> { - let metadata = RadrootsNostrMetadata { - name: sanitize_optional_string(config.name.as_deref()), - display_name: sanitize_optional_string(config.display_name.as_deref()), - about: sanitize_optional_string(config.about.as_deref()), - website: sanitize_optional_string(config.website.as_deref()), - picture: sanitize_optional_string(config.picture.as_deref()), - ..RadrootsNostrMetadata::default() - }; - if metadata.name.is_none() - && metadata.display_name.is_none() - && metadata.about.is_none() - && metadata.website.is_none() - && metadata.picture.is_none() - { - return None; - } - Some(metadata) -} - -fn sanitize_optional_string(value: Option<&str>) -> Option<String> { - let trimmed = value?.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_owned()) - } -} - -fn normalize_optional_string(value: Option<String>) -> Option<String> { - sanitize_optional_string(value.as_deref()) -} - -fn normalize_string_list(values: Vec<String>) -> Vec<String> { - let mut values = values - .into_iter() - .filter_map(|value| normalize_optional_string(Some(value))) - .collect::<Vec<_>>(); - values.sort(); - values.dedup(); - values -} - -fn normalize_metadata(metadata: Option<RadrootsNostrMetadata>) -> Option<RadrootsNostrMetadata> { - let mut metadata = metadata?; - metadata.name = sanitize_optional_string(metadata.name.as_deref()); - metadata.display_name = sanitize_optional_string(metadata.display_name.as_deref()); - metadata.about = sanitize_optional_string(metadata.about.as_deref()); - metadata.website = sanitize_optional_string(metadata.website.as_deref()); - metadata.picture = sanitize_optional_string(metadata.picture.as_deref()); - if !radroots_nostr_metadata_has_fields(&metadata) { - return None; - } - Some(metadata) -} - -fn write_pretty_json<T>(path: &Path, value: &T) -> Result<(), MycError> -where - T: Serialize, -{ - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() - { - fs::create_dir_all(parent).map_err(|source| MycError::DiscoveryIo { - path: parent.to_path_buf(), - source, - })?; - } - let encoded = serde_json::to_string_pretty(value)?; - fs::write(path, encoded).map_err(|source| MycError::DiscoveryIo { - path: path.to_path_buf(), - source, - })?; - Ok(()) -} - -fn read_json_file<T>(path: &Path) -> Result<T, MycError> -where - T: serde::de::DeserializeOwned, -{ - let encoded = fs::read_to_string(path).map_err(|source| MycError::DiscoveryIo { - path: path.to_path_buf(), - source, - })?; - serde_json::from_str(&encoded).map_err(|source| MycError::DiscoveryParse { - path: path.to_path_buf(), - source, - }) -} - -fn prepare_staged_output_dir(output_dir: &Path) -> Result<PathBuf, MycError> { - let parent = output_dir.parent().unwrap_or_else(|| Path::new(".")); - fs::create_dir_all(parent).map_err(|source| MycError::DiscoveryIo { - path: parent.to_path_buf(), - source, - })?; - - let bundle_name = output_dir - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("discovery"); - let staged_output_dir = parent.join(format!( - ".{bundle_name}.staging-{}-{}", - std::process::id(), - now_unix_nanos() - )); - remove_path_if_exists(&staged_output_dir)?; - fs::create_dir_all(&staged_output_dir).map_err(|source| MycError::DiscoveryIo { - path: staged_output_dir.clone(), - source, - })?; - Ok(staged_output_dir) -} - -fn replace_directory_atomically( - staged_output_dir: &Path, - output_dir: &Path, -) -> Result<(), MycError> { - let parent = output_dir.parent().unwrap_or_else(|| Path::new(".")); - let bundle_name = output_dir - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("discovery"); - let backup_dir = parent.join(format!( - ".{bundle_name}.backup-{}-{}", - std::process::id(), - now_unix_nanos() - )); - let had_existing_output = output_dir.exists(); - - if had_existing_output { - remove_path_if_exists(&backup_dir)?; - fs::rename(output_dir, &backup_dir).map_err(|source| MycError::DiscoveryIo { - path: output_dir.to_path_buf(), - source, - })?; - } - - match fs::rename(staged_output_dir, output_dir) { - Ok(()) => { - if had_existing_output { - remove_path_if_exists(&backup_dir)?; - } - Ok(()) - } - Err(source) => { - let staged_cleanup_result = remove_path_if_exists(staged_output_dir); - if had_existing_output && !output_dir.exists() { - let _ = fs::rename(&backup_dir, output_dir); - } - if let Err(cleanup_error) = staged_cleanup_result { - return Err(MycError::InvalidDiscoveryBundle(format!( - "failed to swap staged bundle into place: {source}; additionally failed to clean staged output: {cleanup_error}" - ))); - } - Err(MycError::DiscoveryIo { - path: output_dir.to_path_buf(), - source, - }) - } - } -} - -fn remove_path_if_exists(path: &Path) -> Result<(), MycError> { - let metadata = match fs::metadata(path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(source) => { - return Err(MycError::DiscoveryIo { - path: path.to_path_buf(), - source, - }); - } - }; - - if metadata.is_dir() { - fs::remove_dir_all(path).map_err(|source| MycError::DiscoveryIo { - path: path.to_path_buf(), - source, - })?; - } else { - fs::remove_file(path).map_err(|source| MycError::DiscoveryIo { - path: path.to_path_buf(), - source, - })?; - } - Ok(()) -} - -fn now_unix_nanos() -> u128 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock is before unix epoch") - .as_nanos() -} - -impl MycDiscoveryBundleOutput { - fn validate(&self) -> Result<(), MycError> { - if self.manifest.version != DISCOVERY_BUNDLE_VERSION { - return Err(MycError::InvalidDiscoveryBundle(format!( - "unsupported bundle version `{}`", - self.manifest.version - ))); - } - if self.manifest.domain.trim().is_empty() { - return Err(MycError::InvalidDiscoveryBundle( - "bundle domain must not be empty".to_owned(), - )); - } - if self.manifest.author_public_key_hex.trim().is_empty() - || self.manifest.signer_public_key_hex.trim().is_empty() - { - return Err(MycError::InvalidDiscoveryBundle( - "bundle author and signer pubkeys must not be empty".to_owned(), - )); - } - if self.manifest.nip05_relative_path != DISCOVERY_BUNDLE_NIP05_RELATIVE_PATH { - return Err(MycError::InvalidDiscoveryBundle(format!( - "bundle manifest nip05_relative_path must be `{DISCOVERY_BUNDLE_NIP05_RELATIVE_PATH}`" - ))); - } - if self.manifest.nip89_relative_path != DISCOVERY_BUNDLE_NIP89_FILE_NAME { - return Err(MycError::InvalidDiscoveryBundle(format!( - "bundle manifest nip89_relative_path must be `{DISCOVERY_BUNDLE_NIP89_FILE_NAME}`" - ))); - } - if self.nip05_path != self.output_dir.join(&self.manifest.nip05_relative_path) { - return Err(MycError::InvalidDiscoveryBundle( - "bundle nip05 path does not match the manifest".to_owned(), - )); - } - if self.nip89_handler_path != self.output_dir.join(&self.manifest.nip89_relative_path) { - return Err(MycError::InvalidDiscoveryBundle( - "bundle NIP-89 handler path does not match the manifest".to_owned(), - )); - } - if self.nip05_document.names.get("_").map(String::as_str) - != Some(self.manifest.author_public_key_hex.as_str()) - { - return Err(MycError::InvalidDiscoveryBundle( - "bundle nip05 names._ does not match the manifest author pubkey".to_owned(), - )); - } - if self.nip05_document.nip46.relays != self.manifest.public_relays { - return Err(MycError::InvalidDiscoveryBundle( - "bundle nip05 relays do not match the manifest public relays".to_owned(), - )); - } - if self.nip05_document.nip46.nostrconnect_url != self.manifest.nostrconnect_url { - return Err(MycError::InvalidDiscoveryBundle( - "bundle nip05 nostrconnect_url does not match the manifest".to_owned(), - )); - } - if self.nip89_handler.kinds != vec![NIP46_RPC_KIND] { - return Err(MycError::InvalidDiscoveryBundle( - "bundle NIP-89 handler kinds must be [24133]".to_owned(), - )); - } - if self.nip89_handler.identifier.trim().is_empty() { - return Err(MycError::InvalidDiscoveryBundle( - "bundle NIP-89 handler identifier must not be empty".to_owned(), - )); - } - if self.nip89_handler.relays != self.manifest.public_relays { - return Err(MycError::InvalidDiscoveryBundle( - "bundle NIP-89 handler relays do not match the manifest public relays".to_owned(), - )); - } - if self.nip89_handler.nostrconnect_url != self.manifest.nostrconnect_url { - return Err(MycError::InvalidDiscoveryBundle( - "bundle NIP-89 handler nostrconnect_url does not match the manifest".to_owned(), - )); - } - Ok(()) - } -} - -fn render_nostrconnect_url( - template: &str, - signer_identity: &MycActiveIdentity, - public_relays: &[RadrootsNostrRelayUrl], -) -> Result<String, MycError> { - let signer_public_key = signer_identity.public_identity().public_key(); - let mut serializer = url::form_urlencoded::Serializer::new(String::new()); - for relay in public_relays { - serializer.append_pair("relay", relay.as_str()); - } - let bunker_uri = format!("bunker://{signer_public_key}?{}", serializer.finish()); - let bunker_uri = Uri::parse(&bunker_uri)?.to_string(); - let encoded_bunker_uri: String = - url::form_urlencoded::byte_serialize(bunker_uri.as_bytes()).collect(); - let rendered = template.replace("<nostrconnect>", &encoded_bunker_uri); - nostr::Url::parse(&rendered).map_err(|error| { - MycError::InvalidOperation(format!( - "failed to render discovery.nostrconnect_url_template: {error}" - )) - })?; - Ok(rendered) -} - -#[cfg(test)] -mod tests { - use std::fs; - use std::path::{Path, PathBuf}; - - use crate::host_identity::RadrootsIdentity; - use nostr::JsonUtil; - - use super::{MycDiscoveryContext, build_metadata, verify_bundle, write_pretty_json}; - use crate::MycError; - use crate::app::MycRuntime; - - fn write_identity(path: &Path, secret_key: &str) { - let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); - crate::identity_files::store_encrypted_identity(path, &identity).expect("save identity"); - } - - fn runtime() -> MycRuntime { - let temp = tempfile::tempdir().expect("tempdir").keep(); - let mut config = crate::config::test_config(PathBuf::from(&temp).as_path()); - config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json"); - config.paths.user_identity_path = PathBuf::from(&temp).join("user.json"); - config.discovery.enabled = true; - config.discovery.domain = Some("signer.example.com".to_owned()); - config.discovery.handler_identifier = "myc".to_owned(); - config.discovery.public_relays = vec!["wss://relay.example.com".to_owned()]; - config.discovery.publish_relays = vec!["wss://publish.example.com".to_owned()]; - config.discovery.nostrconnect_url_template = - Some("https://signer.example.com/connect?uri=<nostrconnect>".to_owned()); - config.discovery.nip05_output_path = - Some(PathBuf::from(&temp).join("public/.well-known/nostr.json")); - config.discovery.metadata.name = Some("myc".to_owned()); - config.discovery.metadata.about = Some("remote signer".to_owned()); - config.discovery.app_identity_path = Some(PathBuf::from(&temp).join("app.json")); - write_identity( - &config.paths.signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_identity( - &config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - write_identity( - config - .discovery - .app_identity_path - .as_ref() - .expect("app identity path"), - "3333333333333333333333333333333333333333333333333333333333333333", - ); - MycRuntime::bootstrap(config).expect("runtime") - } - - #[test] - fn build_metadata_ignores_blank_fields() { - let metadata = crate::config::MycDiscoveryMetadataConfig { - name: Some(" ".to_owned()), - about: Some(" ready ".to_owned()), - ..crate::config::MycDiscoveryMetadataConfig::default() - }; - - let built = build_metadata(&metadata).expect("metadata"); - - assert!(built.name.is_none()); - assert_eq!(built.about.as_deref(), Some("ready")); - } - - #[test] - fn render_nip05_document_matches_appendix_shape() { - let runtime = runtime(); - let context = MycDiscoveryContext::from_runtime(&runtime).expect("discovery context"); - - let document = context.render_nip05_document(); - - assert_eq!(document.names.len(), 1); - assert_eq!( - document.names.get("_"), - Some(&context.app_identity().public_key_hex()) - ); - assert_eq!( - document.nip46.relays, - vec!["wss://relay.example.com".to_owned()] - ); - assert!( - document - .nip46 - .nostrconnect_url - .as_deref() - .expect("nostrconnect url") - .contains("bunker%3A%2F%2F") - ); - } - - #[test] - fn render_signed_nip89_event_uses_app_identity_author() { - let runtime = runtime(); - let context = MycDiscoveryContext::from_runtime(&runtime).expect("discovery context"); - - let output = context.render_nip89_output().expect("rendered nip89"); - - assert_eq!( - output.author_public_key_hex, - context.app_identity().public_key_hex() - ); - assert_eq!( - output.signer_public_key_hex, - context.signer_identity().public_key_hex() - ); - assert_eq!(output.event.pubkey, context.app_identity().public_key()); - assert_eq!(output.event.kind.as_u16(), 31_990); - let event_json = output.event.as_json(); - assert!(event_json.contains("\"24133\"")); - assert!(event_json.contains("\"nostrconnect_url\"")); - } - - #[test] - fn write_nip05_document_writes_pretty_json_artifact() { - let runtime = runtime(); - let context = MycDiscoveryContext::from_runtime(&runtime).expect("discovery context"); - let output_path = context - .nip05_output_path() - .expect("configured output path") - .to_path_buf(); - - let output = context - .write_nip05_document(&output_path) - .expect("write nip05 document"); - - let written = fs::read_to_string(&output_path).expect("read output"); - assert_eq!(output.output_path.as_deref(), Some(output_path.as_path())); - assert!(written.contains("\"names\"")); - assert!(written.contains("\"nip46\"")); - assert!(written.contains(&context.app_identity().public_key_hex())); - } - - #[test] - fn write_bundle_writes_deterministic_artifacts() { - let runtime = runtime(); - let context = MycDiscoveryContext::from_runtime(&runtime).expect("discovery context"); - let bundle_dir = runtime.paths().state_dir.join("bundle"); - - let first = context - .write_bundle(&bundle_dir) - .expect("first bundle write"); - let manifest_first = fs::read_to_string(&first.manifest_path).expect("manifest"); - let nip05_first = fs::read_to_string(&first.nip05_path).expect("nip05"); - let nip89_first = fs::read_to_string(&first.nip89_handler_path).expect("nip89"); - - let second = context - .write_bundle(&bundle_dir) - .expect("second bundle write"); - let manifest_second = fs::read_to_string(&second.manifest_path).expect("manifest"); - let nip05_second = fs::read_to_string(&second.nip05_path).expect("nip05"); - let nip89_second = fs::read_to_string(&second.nip89_handler_path).expect("nip89"); - - assert_eq!(first.manifest.version, 1); - assert_eq!(first.manifest.nip05_relative_path, ".well-known/nostr.json"); - assert_eq!(first.manifest.nip89_relative_path, "nip89-handler.json"); - assert_eq!(first.nip05_path, bundle_dir.join(".well-known/nostr.json")); - assert_eq!( - first.nip89_handler_path, - bundle_dir.join("nip89-handler.json") - ); - assert_eq!(manifest_first, manifest_second); - assert_eq!(nip05_first, nip05_second); - assert_eq!(nip89_first, nip89_second); - } - - #[test] - fn write_bundle_replaces_existing_directory_without_leaving_stale_files() { - let runtime = runtime(); - let context = MycDiscoveryContext::from_runtime(&runtime).expect("discovery context"); - let bundle_dir = runtime.paths().state_dir.join("bundle"); - fs::create_dir_all(&bundle_dir).expect("create old bundle dir"); - fs::write(bundle_dir.join("stale.txt"), "stale").expect("write stale file"); - - let bundle = context.write_bundle(&bundle_dir).expect("write bundle"); - - assert_eq!(bundle.output_dir, bundle_dir); - assert!(!bundle.output_dir.join("stale.txt").exists()); - assert!(bundle.manifest_path.exists()); - assert!(bundle.nip05_path.exists()); - assert!(bundle.nip89_handler_path.exists()); - } - - #[test] - fn verify_bundle_rejects_tampered_nip05_author() { - let runtime = runtime(); - let context = MycDiscoveryContext::from_runtime(&runtime).expect("discovery context"); - let bundle_dir = runtime.paths().state_dir.join("bundle"); - let bundle = context.write_bundle(&bundle_dir).expect("write bundle"); - let mut tampered = bundle.nip05_document.clone(); - tampered.names.insert("_".to_owned(), "deadbeef".to_owned()); - write_pretty_json(&bundle.nip05_path, &tampered).expect("rewrite tampered nip05"); - - let error = verify_bundle(&bundle_dir).expect_err("bundle should be invalid"); - - assert!(matches!(error, MycError::InvalidDiscoveryBundle(_))); - assert!( - error - .to_string() - .contains("bundle nip05 names._ does not match the manifest author pubkey") - ); - } -} diff --git a/src/lib.rs b/src/lib.rs @@ -1,30 +1,8 @@ #![forbid(unsafe_code)] -pub mod accounts; -pub mod app; -pub mod audit; -mod audit_sqlite; mod cli_v1; -pub mod config; mod config_v1; -pub mod control; -pub mod custody; -pub mod discovery; -pub mod error; -pub mod host_identity; -pub mod identity_files; -pub mod logging; -pub mod nostr_contract; -pub mod operability; -pub mod outbox; -mod outbox_sqlite; -mod paths; -pub mod persistence; -pub mod policy; mod runtime_context; -pub mod signer; -mod signing_adapter; -pub mod sql; mod state_catalog; mod state_connection; mod state_delivery; @@ -35,76 +13,16 @@ mod state_maintenance; mod state_metadata; mod state_repository; mod state_request; -pub mod transport; -pub use app::{ - MycApp, MycRuntime, MycRuntimePaths, MycSignerBackend, MycSignerContext, MycStartupSnapshot, -}; -pub use audit::{ - MycJsonlOperationAuditStore, MycOperationAuditKind, MycOperationAuditOutcome, - MycOperationAuditRecord, MycOperationAuditStore, -}; -pub use audit_sqlite::MycSqliteOperationAuditStore; pub use cli_v1::{ MycBootstrapProfileV1, MycCliInvocationV1, MycCliV1Error, MycCliV1ErrorKind, MycCommandV1, MycConfigCommandV1, MycIdentityCommandV1, MycStateCommandV1, parse_myc_cli_v1_from, }; -pub use config::{ - MycAuditConfig, MycConfig, MycConnectionApproval, MycCustodyConfig, MycDiscoveryConfig, - MycDiscoveryMetadataConfig, MycIdentityBackend, MycIdentitySourceSpec, MycLoggingConfig, - MycObservabilityConfig, MycPathsConfig, MycPersistenceConfig, MycPolicyConfig, - MycRuntimeAuditBackend, MycRuntimeContractOutput, MycSignerStateBackend, MycTransportConfig, - MycTransportDeliveryPolicy, -}; pub use config_v1::{ MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES, MYC_CONFIG_SCHEMA, MYC_CONFIG_SCHEMA_VERSION, MycConfigDocumentV1, MycConfigProfile, MycConfigV1Error, MycConfigV1ErrorKind, MycConfigValueSource, MycEffectiveConfigV1, parse_myc_config_v1, }; -pub use control::{MycAcceptedConnectionOutput, MycAuthorizedReplayOutput}; -pub use custody::{ - MycActiveIdentity, MycCustodyExportOutput, MycCustodyImportOutput, MycCustodyRotateOutput, - MycIdentityProvider, MycIdentityStatusOutput, MycManagedAccountMutationOutput, - MycManagedAccountSelectionState, MycManagedAccountsOutput, -}; -pub use discovery::{ - MycDiscoveryBundleManifest, MycDiscoveryBundleOutput, MycDiscoveryContext, - MycDiscoveryDiffOutput, MycDiscoveryLiveStatus, MycDiscoveryRelayFetchStatus, - MycDiscoveryRelayRepairResult, MycDiscoveryRelayState, MycDiscoveryRelaySummary, - MycDiscoveryRepairOutcome, MycDiscoveryRepairSummary, MycFetchedLiveNip89Output, - MycLiveNip89Event, MycLiveNip89Group, MycLiveNip89RelayState, MycNip05Document, - MycNip05DocumentSection, MycNip89HandlerDocument, MycNormalizedNip89Handler, - MycPublishedNip89Output, MycRefreshedNip89Output, MycRenderedNip05Output, - MycRenderedNip89Output, diff_live_nip89, fetch_live_nip89, publish_nip89_event, refresh_nip89, - render_nip05_output, verify_bundle, -}; -pub use error::MycError; -pub use operability::{ - MYC_SIGNER_STATUS_CONTRACT_VERSION, MycAuditDecisionCounts, MycCustodyStatusOutput, - MycDeliveryOutboxStatusOutput, MycDeliveryRecoveryStatusOutput, MycDiscoveryStatusOutput, - MycMetricsSnapshot, MycOperationOutcomeCounts, MycPersistenceStatusOutput, MycRelayProbe, - MycRelayProbeAvailability, MycRuntimeAuditPersistenceStatusOutput, MycRuntimeStatus, - MycSignerBackendStatusOutput, MycSignerStatePersistenceStatusOutput, - MycSqliteSchemaStatusOutput, MycStatusFullOutput, MycStatusSignerOutput, - MycStatusSummaryOutput, MycTransportStatusOutput, collect_metrics, collect_status_full, - collect_status_signer, collect_status_summary, render_metrics_text, -}; -pub use outbox::{ - MycDeliveryOutboxJobId, MycDeliveryOutboxKind, MycDeliveryOutboxRecord, - MycDeliveryOutboxStatus, MycDeliveryOutboxStore, -}; -pub use outbox_sqlite::MycSqliteDeliveryOutboxStore; -pub use persistence::{ - MycDeliveryOutboxVerifyRestoreOutput, MycPersistenceBackupOutput, - MycPersistenceBackupStateOutput, MycPersistenceIdentityReferenceBackupOutput, - MycPersistenceIdentityReferenceRestoreOutput, MycPersistenceImportJsonToSqliteOutput, - MycPersistenceImportSelection, MycPersistenceRestoreOutput, MycPersistenceRestoreStateOutput, - MycPersistenceVerifyRestoreOutput, MycRuntimeAuditImportOutput, - MycRuntimeAuditVerifyRestoreOutput, MycSignerStateImportOutput, - MycSignerStateVerifyRestoreOutput, backup_persistence, import_json_to_sqlite, restore_backup, - verify_restored_state, -}; -pub use policy::{MycConnectDecision, MycPolicyContext}; pub use radroots_runtime_paths::{ INSTANCE_ID_MAX_BYTES, InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, RadrootsPlatform, RadrootsServiceInstanceArtifacts, RuntimeContext, @@ -173,9 +91,9 @@ pub use state_maintenance::{ verify_myc_state_backup, }; pub use state_metadata::{ - MYC_OPERATOR_CONTRACT_VERSION, MYC_STATE_APPLICATION_ID, MycExpectedIdentities, - MycExpectedPublicIdentity, MycNormalizedConfigDigest, MycStateMetadata, MycStateMetadataError, - MycStateMetadataErrorKind, MycStatePolicyVersions, + MYC_OPERATOR_CONTRACT_VERSION, MYC_SIGNER_STATUS_CONTRACT_VERSION, MYC_STATE_APPLICATION_ID, + MycExpectedIdentities, MycExpectedPublicIdentity, MycNormalizedConfigDigest, MycStateMetadata, + MycStateMetadataError, MycStateMetadataErrorKind, MycStatePolicyVersions, }; pub use state_repository::{ MycStateRepository, MycStateRepositoryError, MycStateRepositoryErrorKind, @@ -187,4 +105,3 @@ pub use state_request::{ MycSignerRequestAdmission, MycSignerRequestDigest, MycSignerRequestError, MycSignerRequestErrorKind, MycSignerRequestMethod, MycSignerRequestRecord, }; -pub use transport::{MycNostrTransport, MycRelayPublishResult, MycTransportSnapshot}; diff --git a/src/operability/mod.rs b/src/operability/mod.rs @@ -1,1924 +0,0 @@ -pub mod server; - -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use crate::nostr_contract::{RadrootsNostrRelayStatus, RadrootsNostrRelayUrl}; -use crate::signer::prelude::{ - RadrootsNostrLocalSignerCapability, RadrootsNostrRemoteSessionSignerCapability, - RadrootsNostrSignerBackend, RadrootsNostrSignerPublishWorkflowRecord, - RadrootsNostrSignerPublishWorkflowState, RadrootsNostrSignerRequestAuditRecord, - RadrootsNostrSignerRequestDecision, -}; -use crate::sql::{SqlExecutor, SqlxSqliteExecutor}; -use serde::{Deserialize, Serialize}; -use tokio::task::JoinSet; - -use crate::app::MycRuntime; -use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome}; -use crate::config::{ - MycRuntimeAuditBackend, MycRuntimeContractOutput, MycSignerStateBackend, - MycTransportDeliveryPolicy, -}; -use crate::custody::{MycActiveIdentity, MycIdentityStatusOutput}; -use crate::discovery::MycDiscoveryContext; -use crate::error::MycError; -use crate::outbox::{MycDeliveryOutboxRecord, MycDeliveryOutboxStatus, now_unix_secs}; -use crate::transport::MycTransportSnapshot; - -const MYC_RELAY_PROBE_CONCURRENCY_LIMIT: usize = 4; -pub const MYC_SIGNER_STATUS_CONTRACT_VERSION: u32 = 1; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum MycRuntimeStatus { - Healthy, - Degraded, - Unready, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum MycRelayProbeAvailability { - Available, - Unavailable, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycRelayProbe { - pub relay_url: String, - pub availability: MycRelayProbeAvailability, - #[serde(skip_serializing_if = "Option::is_none")] - pub relay_status: Option<String>, - pub connection_attempts: usize, - pub successful_connections: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub latency_ms: Option<u64>, - pub queue_depth: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycTransportStatusOutput { - pub enabled: bool, - pub status: MycRuntimeStatus, - pub ready: bool, - pub configured_relay_count: usize, - pub required_available_relays: usize, - pub available_relay_count: usize, - pub unavailable_relay_count: usize, - pub delivery_policy: MycTransportDeliveryPolicy, - #[serde(skip_serializing_if = "Option::is_none")] - pub delivery_quorum: Option<usize>, - #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub relay_probes: Vec<MycRelayProbe>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycDiscoveryRelayGroupStatusOutput { - pub configured_relay_count: usize, - pub available_relay_count: usize, - pub unavailable_relay_count: usize, - #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub relay_probes: Vec<MycRelayProbe>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycDiscoveryStatusOutput { - pub enabled: bool, - pub status: MycRuntimeStatus, - pub public_relays: MycDiscoveryRelayGroupStatusOutput, - pub publish_relays: MycDiscoveryRelayGroupStatusOutput, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycCustodyStatusOutput { - pub signer: MycIdentityStatusOutput, - pub user: MycIdentityStatusOutput, - #[serde(skip_serializing_if = "Option::is_none")] - pub discovery_app: Option<MycIdentityStatusOutput>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycPersistenceStatusOutput { - pub signer_state: MycSignerStatePersistenceStatusOutput, - pub runtime_audit: MycRuntimeAuditPersistenceStatusOutput, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycSignerBackendStatusOutput { - #[serde(skip_serializing_if = "Option::is_none")] - pub local_signer: Option<RadrootsNostrLocalSignerCapability>, - pub remote_session_count: usize, - #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub remote_sessions: Vec<RadrootsNostrRemoteSessionSignerCapability>, - pub publish_workflow_count: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycDeliveryRecoveryStatusOutput { - pub recorded_at_unix: u64, - pub outcome: MycOperationAuditOutcome, - pub summary: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycDeliveryOutboxStatusOutput { - pub status: MycRuntimeStatus, - pub ready: bool, - pub path: PathBuf, - pub exists: bool, - pub total_job_count: usize, - pub queued_job_count: usize, - pub published_pending_finalize_job_count: usize, - pub finalized_job_count: usize, - pub failed_job_count: usize, - pub unfinished_job_count: usize, - pub critical_unfinished_job_count: usize, - pub blocked_job_count: usize, - pub critical_blocked_job_count: usize, - pub stuck_after_secs: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub oldest_unfinished_age_secs: Option<u64>, - #[serde(skip_serializing_if = "Option::is_none")] - pub oldest_blocked_age_secs: Option<u64>, - #[serde(skip_serializing_if = "Option::is_none")] - pub last_recovery: Option<MycDeliveryRecoveryStatusOutput>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycSignerStatePersistenceStatusOutput { - pub backend: MycSignerStateBackend, - pub path: PathBuf, - pub exists: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub sqlite_schema: Option<MycSqliteSchemaStatusOutput>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycRuntimeAuditPersistenceStatusOutput { - pub backend: MycRuntimeAuditBackend, - pub path: PathBuf, - pub exists: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub sqlite_schema: Option<MycSqliteSchemaStatusOutput>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycSqliteSchemaStatusOutput { - pub ready: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub applied_migration_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub latest_migration: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub journal_mode: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub store_version: Option<u32>, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycStatusFullOutput { - pub status: MycRuntimeStatus, - pub ready: bool, - pub reasons: Vec<String>, - pub runtime_contract: MycRuntimeContractOutput, - pub startup: crate::app::MycStartupSnapshot, - pub signer_backend: MycSignerBackendStatusOutput, - pub custody: MycCustodyStatusOutput, - pub persistence: MycPersistenceStatusOutput, - pub delivery_outbox: MycDeliveryOutboxStatusOutput, - pub transport: MycTransportStatusOutput, - pub discovery: MycDiscoveryStatusOutput, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycStatusSignerOutput { - pub status_contract_version: u32, - pub status: MycRuntimeStatus, - pub ready: bool, - pub reasons: Vec<String>, - pub runtime_contract: MycRuntimeContractOutput, - pub signer_backend: MycSignerBackendStatusOutput, - pub custody: MycCustodyStatusOutput, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycStatusSummaryOutput { - pub status: MycRuntimeStatus, - pub ready: bool, - pub reasons: Vec<String>, - pub instance_name: String, - pub runtime_contract: MycRuntimeContractOutput, - pub signer_backend: MycSignerBackendStatusOutput, - pub custody: MycCustodyStatusOutput, - pub persistence: MycPersistenceStatusOutput, - pub delivery_outbox: MycDeliveryOutboxStatusOutput, - pub transport: MycTransportStatusOutput, - pub discovery: MycDiscoveryStatusOutput, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] -pub struct MycAuditDecisionCounts { - pub allowed: usize, - pub denied: usize, - pub challenged: usize, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] -pub struct MycOperationOutcomeCounts { - pub succeeded: usize, - pub rejected: usize, - pub restored: usize, - pub unavailable: usize, - pub missing: usize, - pub matched: usize, - pub drifted: usize, - pub conflicted: usize, - pub skipped: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycMetricsSnapshot { - pub signer_request_total: usize, - pub signer_request_decisions: MycAuditDecisionCounts, - pub runtime_operation_total: usize, - pub runtime_operation_outcomes: MycOperationOutcomeCounts, - pub runtime_operation_by_kind: BTreeMap<String, MycOperationOutcomeCounts>, - pub runtime_aggregate_publish_rejection_count: usize, - pub runtime_repair_success_count: usize, - pub runtime_repair_rejection_count: usize, - pub runtime_unavailable_count: usize, - pub runtime_replay_restore_count: usize, - pub delivery_recovery_success_count: usize, - pub delivery_recovery_rejection_count: usize, - pub delivery_outbox_total: usize, - pub delivery_outbox_queued_count: usize, - pub delivery_outbox_published_pending_finalize_count: usize, - pub delivery_outbox_failed_count: usize, - pub delivery_outbox_finalized_count: usize, - pub delivery_outbox_unfinished_count: usize, - pub delivery_outbox_critical_unfinished_count: usize, - pub delivery_outbox_blocked_count: usize, - pub delivery_outbox_critical_blocked_count: usize, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct MycLiveMetricsState { - signer_request_total: usize, - signer_request_decisions: MycAuditDecisionCounts, - runtime_operation_total: usize, - runtime_operation_outcomes: MycOperationOutcomeCounts, - runtime_operation_by_kind: BTreeMap<String, MycOperationOutcomeCounts>, - runtime_aggregate_publish_rejection_count: usize, - runtime_repair_success_count: usize, - runtime_repair_rejection_count: usize, - runtime_unavailable_count: usize, - runtime_replay_restore_count: usize, - delivery_recovery_success_count: usize, - delivery_recovery_rejection_count: usize, -} - -pub(crate) type MycLiveMetricsHandle = Arc<Mutex<MycLiveMetricsState>>; - -#[derive(Debug, Clone, PartialEq, Eq)] -struct MycTransportStatusEvaluation { - output: MycTransportStatusOutput, - reasons: Vec<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct MycCustodyStatusEvaluation { - output: MycCustodyStatusOutput, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct MycPersistenceStatusEvaluation { - output: MycPersistenceStatusOutput, - reasons: Vec<String>, - status: Option<MycRuntimeStatus>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct MycDeliveryOutboxStatusEvaluation { - output: MycDeliveryOutboxStatusOutput, - reasons: Vec<String>, -} - -#[derive(Debug, Deserialize)] -struct MycSqliteAppliedCountRow { - applied_count: u64, -} - -#[derive(Debug, Deserialize)] -struct MycSqliteNamedRow { - name: String, -} - -#[derive(Debug, Deserialize)] -struct MycSqliteJournalModeRow { - journal_mode: String, -} - -#[derive(Debug, Deserialize)] -struct MycSqliteStoreVersionRow { - store_version: u64, -} - -pub async fn collect_status_full(runtime: &MycRuntime) -> Result<MycStatusFullOutput, MycError> { - let snapshot = runtime.snapshot(); - let signer_backend = collect_signer_backend_status(runtime)?; - let custody = collect_custody_status(runtime)?; - let persistence = collect_persistence_status(runtime); - let delivery_outbox = collect_delivery_outbox_status(runtime)?; - let transport = collect_transport_status(runtime).await?; - let discovery = collect_discovery_status(runtime).await?; - let mut status = combine_runtime_status( - transport.output.status, - if discovery.output.enabled { - Some(discovery.output.status) - } else { - None - }, - ); - let mut reasons = transport.reasons; - reasons.extend(discovery.reasons); - status = worse_runtime_status(status, delivery_outbox.output.status); - reasons.extend(delivery_outbox.reasons.clone()); - if let Some(persistence_status) = persistence.status { - status = worse_runtime_status(status, persistence_status); - } - reasons.extend(persistence.reasons.clone()); - if custody - .output - .discovery_app - .as_ref() - .is_some_and(|status_output| !status_output.resolved) - && status != MycRuntimeStatus::Unready - { - status = MycRuntimeStatus::Degraded; - reasons.push("discovery app identity could not be resolved".to_owned()); - } - let ready = transport.output.ready && delivery_outbox.output.ready; - Ok(MycStatusFullOutput { - status, - ready, - reasons, - runtime_contract: runtime.config().runtime_contract_output(), - startup: snapshot, - signer_backend, - custody: custody.output, - persistence: persistence.output, - delivery_outbox: delivery_outbox.output, - transport: transport.output, - discovery: discovery.output, - }) -} - -pub fn collect_status_signer(runtime: &MycRuntime) -> Result<MycStatusSignerOutput, MycError> { - let signer_backend = collect_signer_backend_status(runtime)?; - let custody = collect_custody_status(runtime)?; - let mut reasons = Vec::new(); - - if !custody.output.signer.resolved { - reasons.push("signer identity could not be resolved".to_owned()); - } - if !custody.output.user.resolved { - reasons.push("user identity could not be resolved".to_owned()); - } - match signer_backend.local_signer.as_ref() { - Some(local_signer) if local_signer.is_secret_backed() => {} - Some(_) => reasons.push("local signer capability is not secret-backed".to_owned()), - None => reasons.push("local signer capability is unavailable".to_owned()), - } - - let ready = reasons.is_empty(); - Ok(MycStatusSignerOutput { - status_contract_version: MYC_SIGNER_STATUS_CONTRACT_VERSION, - status: if ready { - MycRuntimeStatus::Healthy - } else { - MycRuntimeStatus::Unready - }, - ready, - reasons, - runtime_contract: runtime.config().runtime_contract_output(), - signer_backend, - custody: custody.output, - }) -} - -pub async fn collect_status_summary( - runtime: &MycRuntime, -) -> Result<MycStatusSummaryOutput, MycError> { - let full = collect_status_full(runtime).await?; - Ok(MycStatusSummaryOutput { - status: full.status, - ready: full.ready, - reasons: full.reasons, - instance_name: full.startup.instance_name, - runtime_contract: full.runtime_contract, - signer_backend: MycSignerBackendStatusOutput { - local_signer: full.signer_backend.local_signer.clone(), - remote_session_count: full.signer_backend.remote_session_count, - remote_sessions: Vec::new(), - publish_workflow_count: full.signer_backend.publish_workflow_count, - }, - custody: full.custody, - persistence: full.persistence, - delivery_outbox: full.delivery_outbox, - transport: MycTransportStatusOutput { - relay_probes: Vec::new(), - ..full.transport - }, - discovery: MycDiscoveryStatusOutput { - enabled: full.discovery.enabled, - status: full.discovery.status, - public_relays: MycDiscoveryRelayGroupStatusOutput { - relay_probes: Vec::new(), - ..full.discovery.public_relays - }, - publish_relays: MycDiscoveryRelayGroupStatusOutput { - relay_probes: Vec::new(), - ..full.discovery.publish_relays - }, - }, - }) -} - -fn collect_custody_status(runtime: &MycRuntime) -> Result<MycCustodyStatusEvaluation, MycError> { - let signer = runtime - .signer_context() - .signer_identity_provider() - .resolved_status(runtime.signer_identity()); - let user = runtime - .signer_context() - .user_identity_provider() - .resolved_status(runtime.user_identity()); - let discovery_app = if runtime.config().discovery.enabled { - match runtime.config().discovery.app_identity_source() { - Some(source) => Some( - crate::custody::MycIdentityProvider::from_source( - "discovery app", - source, - Duration::from_secs(runtime.config().custody.external_command_timeout_secs), - )? - .probe_status(), - ), - None => Some( - runtime - .signer_context() - .signer_identity_provider() - .resolved_status(runtime.signer_identity()) - .with_inherited_from("signer"), - ), - } - } else { - None - }; - - Ok(MycCustodyStatusEvaluation { - output: MycCustodyStatusOutput { - signer, - user, - discovery_app, - }, - }) -} - -fn collect_signer_backend_status( - runtime: &MycRuntime, -) -> Result<MycSignerBackendStatusOutput, MycError> { - let backend = runtime.signer_backend(); - let capabilities = backend.capabilities()?; - let publish_workflow_count = backend.list_publish_workflows()?.len(); - Ok(MycSignerBackendStatusOutput { - local_signer: capabilities.local_signer, - remote_session_count: capabilities.remote_sessions.len(), - remote_sessions: capabilities.remote_sessions, - publish_workflow_count, - }) -} - -fn collect_persistence_status(runtime: &MycRuntime) -> MycPersistenceStatusEvaluation { - let signer_state_backend = runtime.config().persistence.signer_state_backend; - let runtime_audit_backend = runtime.config().persistence.runtime_audit_backend; - let signer_state = MycSignerStatePersistenceStatusOutput { - backend: signer_state_backend, - path: runtime.paths().signer_state_path.clone(), - exists: runtime.paths().signer_state_path.exists(), - sqlite_schema: match signer_state_backend { - MycSignerStateBackend::JsonFile => None, - MycSignerStateBackend::Sqlite => Some(inspect_signer_state_sqlite_schema( - runtime.paths().signer_state_path.as_path(), - )), - }, - }; - let runtime_audit = MycRuntimeAuditPersistenceStatusOutput { - backend: runtime_audit_backend, - path: runtime.paths().runtime_audit_path.clone(), - exists: runtime.paths().runtime_audit_path.exists(), - sqlite_schema: match runtime_audit_backend { - MycRuntimeAuditBackend::JsonlFile => None, - MycRuntimeAuditBackend::Sqlite => Some(inspect_runtime_audit_sqlite_schema( - runtime.paths().runtime_audit_path.as_path(), - )), - }, - }; - - let mut reasons = Vec::new(); - if signer_state - .sqlite_schema - .as_ref() - .is_some_and(|schema| !schema.ready) - { - reasons.push(format!( - "signer-state sqlite schema at {} is not ready", - signer_state.path.display() - )); - } - if runtime_audit - .sqlite_schema - .as_ref() - .is_some_and(|schema| !schema.ready) - { - reasons.push(format!( - "runtime-audit sqlite schema at {} is not ready", - runtime_audit.path.display() - )); - } - let status = if reasons.is_empty() { - None - } else { - Some(MycRuntimeStatus::Degraded) - }; - - MycPersistenceStatusEvaluation { - output: MycPersistenceStatusOutput { - signer_state, - runtime_audit, - }, - reasons, - status, - } -} - -pub fn collect_metrics(runtime: &MycRuntime) -> Result<MycMetricsSnapshot, MycError> { - let outbox_status = collect_delivery_outbox_status(runtime)?; - Ok(runtime.metrics_snapshot(&outbox_status.output)) -} - -pub fn render_metrics_text(snapshot: &MycMetricsSnapshot) -> String { - let mut lines = Vec::new(); - push_counter( - &mut lines, - "myc_signer_request_total", - snapshot.signer_request_total, - ); - push_labeled_counter( - &mut lines, - "myc_signer_request_decision_total", - "decision", - "allowed", - snapshot.signer_request_decisions.allowed, - ); - push_labeled_counter( - &mut lines, - "myc_signer_request_decision_total", - "decision", - "denied", - snapshot.signer_request_decisions.denied, - ); - push_labeled_counter( - &mut lines, - "myc_signer_request_decision_total", - "decision", - "challenged", - snapshot.signer_request_decisions.challenged, - ); - - push_counter( - &mut lines, - "myc_runtime_operation_total", - snapshot.runtime_operation_total, - ); - push_outcome_counters( - &mut lines, - "myc_runtime_operation_outcome_total", - &snapshot.runtime_operation_outcomes, - ); - for (kind, counts) in &snapshot.runtime_operation_by_kind { - push_outcome_counters_with_extra_label( - &mut lines, - "myc_runtime_operation_kind_total", - "kind", - kind, - counts, - ); - } - push_counter( - &mut lines, - "myc_runtime_aggregate_publish_rejection_total", - snapshot.runtime_aggregate_publish_rejection_count, - ); - push_counter( - &mut lines, - "myc_runtime_repair_success_total", - snapshot.runtime_repair_success_count, - ); - push_counter( - &mut lines, - "myc_runtime_repair_rejection_total", - snapshot.runtime_repair_rejection_count, - ); - push_counter( - &mut lines, - "myc_runtime_unavailable_total", - snapshot.runtime_unavailable_count, - ); - push_counter( - &mut lines, - "myc_runtime_replay_restore_total", - snapshot.runtime_replay_restore_count, - ); - push_counter( - &mut lines, - "myc_delivery_recovery_success_total", - snapshot.delivery_recovery_success_count, - ); - push_counter( - &mut lines, - "myc_delivery_recovery_rejection_total", - snapshot.delivery_recovery_rejection_count, - ); - push_counter( - &mut lines, - "myc_delivery_outbox_total", - snapshot.delivery_outbox_total, - ); - push_counter( - &mut lines, - "myc_delivery_outbox_queued_total", - snapshot.delivery_outbox_queued_count, - ); - push_counter( - &mut lines, - "myc_delivery_outbox_published_pending_finalize_total", - snapshot.delivery_outbox_published_pending_finalize_count, - ); - push_counter( - &mut lines, - "myc_delivery_outbox_failed_total", - snapshot.delivery_outbox_failed_count, - ); - push_counter( - &mut lines, - "myc_delivery_outbox_finalized_total", - snapshot.delivery_outbox_finalized_count, - ); - push_counter( - &mut lines, - "myc_delivery_outbox_unfinished_total", - snapshot.delivery_outbox_unfinished_count, - ); - push_counter( - &mut lines, - "myc_delivery_outbox_critical_unfinished_total", - snapshot.delivery_outbox_critical_unfinished_count, - ); - push_counter( - &mut lines, - "myc_delivery_outbox_blocked_total", - snapshot.delivery_outbox_blocked_count, - ); - push_counter( - &mut lines, - "myc_delivery_outbox_critical_blocked_total", - snapshot.delivery_outbox_critical_blocked_count, - ); - - lines.join("\n") -} - -impl MycLiveMetricsState { - pub(crate) fn from_records( - signer_request_audit: &[RadrootsNostrSignerRequestAuditRecord], - runtime_operation_audit: &[crate::audit::MycOperationAuditRecord], - ) -> Self { - let mut state = Self::default(); - for record in signer_request_audit { - state.record_signer_request_audit(record); - } - for record in runtime_operation_audit { - state.record_runtime_operation(record); - } - state - } - - pub(crate) fn record_signer_request_audit( - &mut self, - record: &RadrootsNostrSignerRequestAuditRecord, - ) { - self.signer_request_total += 1; - match record.decision { - RadrootsNostrSignerRequestDecision::Allowed => { - self.signer_request_decisions.allowed += 1; - } - RadrootsNostrSignerRequestDecision::Denied => { - self.signer_request_decisions.denied += 1; - } - RadrootsNostrSignerRequestDecision::Challenged => { - self.signer_request_decisions.challenged += 1; - } - } - } - - pub(crate) fn record_runtime_operation( - &mut self, - record: &crate::audit::MycOperationAuditRecord, - ) { - self.runtime_operation_total += 1; - increment_outcome_counts(&mut self.runtime_operation_outcomes, record.outcome); - increment_outcome_counts( - self.runtime_operation_by_kind - .entry(operation_kind_label(record.operation)) - .or_default(), - record.outcome, - ); - if is_aggregate_publish_operation(record.operation) - && record.outcome == MycOperationAuditOutcome::Rejected - { - self.runtime_aggregate_publish_rejection_count += 1; - } - if record.operation == MycOperationAuditKind::DiscoveryHandlerRepair { - match record.outcome { - MycOperationAuditOutcome::Succeeded => self.runtime_repair_success_count += 1, - MycOperationAuditOutcome::Rejected => self.runtime_repair_rejection_count += 1, - _ => {} - } - } - if record.outcome == MycOperationAuditOutcome::Unavailable { - self.runtime_unavailable_count += 1; - } - if record.operation == MycOperationAuditKind::AuthReplayRestore - && record.outcome == MycOperationAuditOutcome::Restored - { - self.runtime_replay_restore_count += 1; - } - if record.operation == MycOperationAuditKind::DeliveryRecovery { - match record.outcome { - MycOperationAuditOutcome::Succeeded => self.delivery_recovery_success_count += 1, - MycOperationAuditOutcome::Rejected => { - self.delivery_recovery_rejection_count += 1; - } - _ => {} - } - } - } - - pub(crate) fn snapshot( - &self, - outbox_status: &MycDeliveryOutboxStatusOutput, - ) -> MycMetricsSnapshot { - MycMetricsSnapshot { - signer_request_total: self.signer_request_total, - signer_request_decisions: self.signer_request_decisions.clone(), - runtime_operation_total: self.runtime_operation_total, - runtime_operation_outcomes: self.runtime_operation_outcomes.clone(), - runtime_operation_by_kind: self.runtime_operation_by_kind.clone(), - runtime_aggregate_publish_rejection_count: self - .runtime_aggregate_publish_rejection_count, - runtime_repair_success_count: self.runtime_repair_success_count, - runtime_repair_rejection_count: self.runtime_repair_rejection_count, - runtime_unavailable_count: self.runtime_unavailable_count, - runtime_replay_restore_count: self.runtime_replay_restore_count, - delivery_recovery_success_count: self.delivery_recovery_success_count, - delivery_recovery_rejection_count: self.delivery_recovery_rejection_count, - delivery_outbox_total: outbox_status.total_job_count, - delivery_outbox_queued_count: outbox_status.queued_job_count, - delivery_outbox_published_pending_finalize_count: outbox_status - .published_pending_finalize_job_count, - delivery_outbox_failed_count: outbox_status.failed_job_count, - delivery_outbox_finalized_count: outbox_status.finalized_job_count, - delivery_outbox_unfinished_count: outbox_status.unfinished_job_count, - delivery_outbox_critical_unfinished_count: outbox_status.critical_unfinished_job_count, - delivery_outbox_blocked_count: outbox_status.blocked_job_count, - delivery_outbox_critical_blocked_count: outbox_status.critical_blocked_job_count, - } - } -} - -pub fn increment_outcome_counts( - counts: &mut MycOperationOutcomeCounts, - outcome: MycOperationAuditOutcome, -) { - match outcome { - MycOperationAuditOutcome::Succeeded => counts.succeeded += 1, - MycOperationAuditOutcome::Rejected => counts.rejected += 1, - MycOperationAuditOutcome::Restored => counts.restored += 1, - MycOperationAuditOutcome::Unavailable => counts.unavailable += 1, - MycOperationAuditOutcome::Missing => counts.missing += 1, - MycOperationAuditOutcome::Matched => counts.matched += 1, - MycOperationAuditOutcome::Drifted => counts.drifted += 1, - MycOperationAuditOutcome::Conflicted => counts.conflicted += 1, - MycOperationAuditOutcome::Skipped => counts.skipped += 1, - } -} - -pub fn operation_kind_label(kind: MycOperationAuditKind) -> String { - match kind { - MycOperationAuditKind::DeliveryRecovery => "delivery_recovery".to_owned(), - MycOperationAuditKind::ListenerResponsePublish => "listener_response_publish".to_owned(), - MycOperationAuditKind::ConnectAcceptPublish => "connect_accept_publish".to_owned(), - MycOperationAuditKind::AuthReplayPublish => "auth_replay_publish".to_owned(), - MycOperationAuditKind::AuthReplayRestore => "auth_replay_restore".to_owned(), - MycOperationAuditKind::DiscoveryHandlerFetch => "discovery_handler_fetch".to_owned(), - MycOperationAuditKind::DiscoveryHandlerPublish => "discovery_handler_publish".to_owned(), - MycOperationAuditKind::DiscoveryHandlerCompare => "discovery_handler_compare".to_owned(), - MycOperationAuditKind::DiscoveryHandlerRefresh => "discovery_handler_refresh".to_owned(), - MycOperationAuditKind::DiscoveryHandlerRepair => "discovery_handler_repair".to_owned(), - } -} - -pub fn is_aggregate_publish_operation(kind: MycOperationAuditKind) -> bool { - matches!( - kind, - MycOperationAuditKind::ListenerResponsePublish - | MycOperationAuditKind::ConnectAcceptPublish - | MycOperationAuditKind::AuthReplayPublish - | MycOperationAuditKind::DiscoveryHandlerPublish - ) -} - -async fn collect_transport_status( - runtime: &MycRuntime, -) -> Result<MycTransportStatusEvaluation, MycError> { - let snapshot = runtime.snapshot().transport; - if !snapshot.enabled { - return Ok(MycTransportStatusEvaluation { - output: MycTransportStatusOutput { - enabled: false, - status: MycRuntimeStatus::Unready, - ready: false, - configured_relay_count: 0, - required_available_relays: 0, - available_relay_count: 0, - unavailable_relay_count: 0, - delivery_policy: snapshot.delivery_policy, - delivery_quorum: snapshot.delivery_quorum, - relay_probes: Vec::new(), - }, - reasons: vec!["transport is disabled".to_owned()], - }); - } - - let Some(transport) = runtime.transport() else { - return Ok(MycTransportStatusEvaluation { - output: MycTransportStatusOutput { - enabled: true, - status: MycRuntimeStatus::Unready, - ready: false, - configured_relay_count: 0, - required_available_relays: 0, - available_relay_count: 0, - unavailable_relay_count: 0, - delivery_policy: snapshot.delivery_policy, - delivery_quorum: snapshot.delivery_quorum, - relay_probes: Vec::new(), - }, - reasons: vec!["transport is enabled but no transport client was prepared".to_owned()], - }); - }; - - let relay_probes = probe_relays( - runtime.signer_identity(), - transport.relays(), - transport.connect_timeout_secs(), - ) - .await?; - let available_relay_count = relay_probes - .iter() - .filter(|probe| probe.availability == MycRelayProbeAvailability::Available) - .count(); - let configured_relay_count = relay_probes.len(); - let unavailable_relay_count = configured_relay_count.saturating_sub(available_relay_count); - let required_available_relays = - required_available_relays(&snapshot, configured_relay_count).unwrap_or(usize::MAX); - let ready = available_relay_count >= required_available_relays; - let status = if !ready { - MycRuntimeStatus::Unready - } else if unavailable_relay_count > 0 { - MycRuntimeStatus::Degraded - } else { - MycRuntimeStatus::Healthy - }; - let mut reasons = Vec::new(); - if !ready { - reasons.push(format!( - "transport availability {available_relay_count}/{} does not satisfy delivery policy {}", - configured_relay_count, - snapshot.delivery_policy.as_str() - )); - } else if unavailable_relay_count > 0 { - reasons.push(format!( - "{unavailable_relay_count} transport relay(s) are unavailable" - )); - } - - Ok(MycTransportStatusEvaluation { - output: MycTransportStatusOutput { - enabled: true, - status, - ready, - configured_relay_count, - required_available_relays, - available_relay_count, - unavailable_relay_count, - delivery_policy: snapshot.delivery_policy, - delivery_quorum: snapshot.delivery_quorum, - relay_probes, - }, - reasons, - }) -} - -struct MycDiscoveryStatusEvaluation { - output: MycDiscoveryStatusOutput, - reasons: Vec<String>, -} - -async fn collect_discovery_status( - runtime: &MycRuntime, -) -> Result<MycDiscoveryStatusEvaluation, MycError> { - if !runtime.config().discovery.enabled { - return Ok(MycDiscoveryStatusEvaluation { - output: MycDiscoveryStatusOutput { - enabled: false, - status: MycRuntimeStatus::Healthy, - public_relays: MycDiscoveryRelayGroupStatusOutput { - configured_relay_count: 0, - available_relay_count: 0, - unavailable_relay_count: 0, - relay_probes: Vec::new(), - }, - publish_relays: MycDiscoveryRelayGroupStatusOutput { - configured_relay_count: 0, - available_relay_count: 0, - unavailable_relay_count: 0, - relay_probes: Vec::new(), - }, - }, - reasons: Vec::new(), - }); - } - - let context = MycDiscoveryContext::from_runtime(runtime)?; - let public_relays = runtime - .config() - .discovery - .resolved_public_relays(&runtime.config().transport)?; - let public_relays = probe_relays( - context.app_identity(), - public_relays.as_slice(), - context.connect_timeout_secs(), - ) - .await?; - let publish_relays = probe_relays( - context.app_identity(), - context.publish_relays(), - context.connect_timeout_secs(), - ) - .await?; - let public_group = summarize_discovery_relay_group(public_relays); - let publish_group = summarize_discovery_relay_group(publish_relays); - - let status = - if public_group.unavailable_relay_count > 0 || publish_group.unavailable_relay_count > 0 { - MycRuntimeStatus::Degraded - } else { - MycRuntimeStatus::Healthy - }; - let mut reasons = Vec::new(); - if public_group.unavailable_relay_count > 0 { - reasons.push(format!( - "{} discovery public relay(s) are unavailable", - public_group.unavailable_relay_count - )); - } - if publish_group.unavailable_relay_count > 0 { - reasons.push(format!( - "{} discovery publish relay(s) are unavailable", - publish_group.unavailable_relay_count - )); - } - - Ok(MycDiscoveryStatusEvaluation { - output: MycDiscoveryStatusOutput { - enabled: true, - status, - public_relays: public_group, - publish_relays: publish_group, - }, - reasons, - }) -} - -fn summarize_discovery_relay_group( - relay_probes: Vec<MycRelayProbe>, -) -> MycDiscoveryRelayGroupStatusOutput { - let configured_relay_count = relay_probes.len(); - let available_relay_count = relay_probes - .iter() - .filter(|probe| probe.availability == MycRelayProbeAvailability::Available) - .count(); - let unavailable_relay_count = configured_relay_count.saturating_sub(available_relay_count); - MycDiscoveryRelayGroupStatusOutput { - configured_relay_count, - available_relay_count, - unavailable_relay_count, - relay_probes, - } -} - -fn collect_delivery_outbox_status( - runtime: &MycRuntime, -) -> Result<MycDeliveryOutboxStatusEvaluation, MycError> { - let outbox_records = runtime.delivery_outbox_store().list_all()?; - let workflow_by_id = runtime - .signer_backend() - .list_publish_workflows()? - .into_iter() - .map(|workflow| (workflow.workflow_id.to_string(), workflow)) - .collect::<BTreeMap<_, _>>(); - let now_unix = now_unix_secs(); - let stuck_after_secs = delivery_outbox_stuck_after_secs(runtime); - let path = runtime.paths().delivery_outbox_path.clone(); - let exists = path.exists(); - let mut queued_job_count = 0usize; - let mut published_pending_finalize_job_count = 0usize; - let mut finalized_job_count = 0usize; - let mut failed_job_count = 0usize; - let mut unfinished_job_count = 0usize; - let mut critical_unfinished_job_count = 0usize; - let mut blocked_job_count = 0usize; - let mut critical_blocked_job_count = 0usize; - let mut oldest_unfinished_age_secs = None; - let mut oldest_blocked_age_secs = None; - - for record in &outbox_records { - match record.status { - MycDeliveryOutboxStatus::Queued => queued_job_count += 1, - MycDeliveryOutboxStatus::PublishedPendingFinalize => { - published_pending_finalize_job_count += 1; - } - MycDeliveryOutboxStatus::Finalized => finalized_job_count += 1, - MycDeliveryOutboxStatus::Failed => failed_job_count += 1, - } - - if !is_delivery_outbox_unfinished(record) { - continue; - } - - unfinished_job_count += 1; - if is_critical_delivery_outbox_job(record) { - critical_unfinished_job_count += 1; - } - let age_secs = delivery_outbox_record_age_secs(record, now_unix); - oldest_unfinished_age_secs = - Some(oldest_unfinished_age_secs.map_or(age_secs, |current: u64| current.max(age_secs))); - - if let Some(is_critical) = classify_blocked_delivery_outbox_record( - record, - &workflow_by_id, - age_secs, - stuck_after_secs, - ) { - blocked_job_count += 1; - if is_critical { - critical_blocked_job_count += 1; - } - oldest_blocked_age_secs = Some( - oldest_blocked_age_secs.map_or(age_secs, |current: u64| current.max(age_secs)), - ); - } - } - - let last_recovery = latest_delivery_recovery_status(runtime)?; - let mut reasons = Vec::new(); - if !exists { - reasons.push(format!( - "delivery outbox persistence file at {} is missing", - path.display() - )); - } - if critical_blocked_job_count > 0 { - reasons.push(format!( - "{critical_blocked_job_count} critical delivery outbox job(s) are blocked" - )); - } - let noncritical_blocked_job_count = - blocked_job_count.saturating_sub(critical_blocked_job_count); - if noncritical_blocked_job_count > 0 { - reasons.push(format!( - "{noncritical_blocked_job_count} non-critical delivery outbox job(s) are blocked" - )); - } - - let (status, ready) = if !exists || critical_blocked_job_count > 0 { - (MycRuntimeStatus::Unready, false) - } else if blocked_job_count > 0 { - (MycRuntimeStatus::Degraded, true) - } else { - (MycRuntimeStatus::Healthy, true) - }; - - Ok(MycDeliveryOutboxStatusEvaluation { - output: MycDeliveryOutboxStatusOutput { - status, - ready, - path, - exists, - total_job_count: outbox_records.len(), - queued_job_count, - published_pending_finalize_job_count, - finalized_job_count, - failed_job_count, - unfinished_job_count, - critical_unfinished_job_count, - blocked_job_count, - critical_blocked_job_count, - stuck_after_secs, - oldest_unfinished_age_secs, - oldest_blocked_age_secs, - last_recovery, - }, - reasons, - }) -} - -fn latest_delivery_recovery_status( - runtime: &MycRuntime, -) -> Result<Option<MycDeliveryRecoveryStatusOutput>, MycError> { - let latest = runtime - .operation_audit_store() - .list_all()? - .into_iter() - .filter(|record| record.operation == MycOperationAuditKind::DeliveryRecovery) - .max_by_key(|record| record.recorded_at_unix); - Ok(latest.map(|record| MycDeliveryRecoveryStatusOutput { - recorded_at_unix: record.recorded_at_unix, - outcome: record.outcome, - summary: record.relay_outcome_summary, - })) -} - -fn delivery_outbox_stuck_after_secs(runtime: &MycRuntime) -> u64 { - let transport = &runtime.config().transport; - let mut total_millis = transport - .connect_timeout_secs - .saturating_mul(1000) - .saturating_mul(transport.publish_max_attempts as u64); - for completed_attempt in 1..transport.publish_max_attempts { - total_millis = - total_millis.saturating_add(delivery_outbox_backoff_millis(runtime, completed_attempt)); - } - total_millis.saturating_add(999) / 1000 -} - -fn delivery_outbox_backoff_millis(runtime: &MycRuntime, completed_attempt_number: usize) -> u64 { - let transport = &runtime.config().transport; - let exponent = completed_attempt_number.saturating_sub(1) as u32; - let multiplier = 1u64.checked_shl(exponent).unwrap_or(u64::MAX); - let scaled = transport - .publish_initial_backoff_millis - .saturating_mul(multiplier); - scaled.min(transport.publish_max_backoff_millis) -} - -fn is_delivery_outbox_unfinished(record: &MycDeliveryOutboxRecord) -> bool { - matches!( - record.status, - MycDeliveryOutboxStatus::Queued | MycDeliveryOutboxStatus::PublishedPendingFinalize - ) || (record.status == MycDeliveryOutboxStatus::Failed - && record.kind == crate::outbox::MycDeliveryOutboxKind::LogoutAcknowledgementPublish) -} - -fn is_critical_delivery_outbox_job(record: &MycDeliveryOutboxRecord) -> bool { - record.kind != crate::outbox::MycDeliveryOutboxKind::DiscoveryHandlerPublish -} - -fn delivery_outbox_record_age_secs(record: &MycDeliveryOutboxRecord, now_unix: u64) -> u64 { - now_unix.saturating_sub(record.updated_at_unix) -} - -fn classify_blocked_delivery_outbox_record( - record: &MycDeliveryOutboxRecord, - workflow_by_id: &BTreeMap<String, RadrootsNostrSignerPublishWorkflowRecord>, - age_secs: u64, - stuck_after_secs: u64, -) -> Option<bool> { - if !is_delivery_outbox_unfinished(record) { - return None; - } - - let is_critical = is_critical_delivery_outbox_job(record); - match record.kind { - crate::outbox::MycDeliveryOutboxKind::DiscoveryHandlerPublish => { - if record.signer_publish_workflow_id.is_some() { - return Some(false); - } - } - crate::outbox::MycDeliveryOutboxKind::ConnectAcceptPublish - | crate::outbox::MycDeliveryOutboxKind::AuthReplayPublish => { - if record.signer_publish_workflow_id.is_none() { - return Some(true); - } - } - crate::outbox::MycDeliveryOutboxKind::ListenerResponsePublish => {} - crate::outbox::MycDeliveryOutboxKind::LogoutAcknowledgementPublish => { - if record.signer_publish_workflow_id.is_some() || record.connection_id.is_none() { - return Some(true); - } - } - } - - if let Some(workflow_id) = record.signer_publish_workflow_id.as_ref() { - let Some(workflow) = workflow_by_id.get(workflow_id.as_str()) else { - return Some(is_critical); - }; - let expected_state = match record.status { - MycDeliveryOutboxStatus::Queued => { - RadrootsNostrSignerPublishWorkflowState::PendingPublish - } - MycDeliveryOutboxStatus::PublishedPendingFinalize => { - RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize - } - MycDeliveryOutboxStatus::Finalized | MycDeliveryOutboxStatus::Failed => { - return None; - } - }; - if workflow.state != expected_state { - return Some(is_critical); - } - } - - if age_secs > stuck_after_secs { - return Some(is_critical); - } - - None -} - -fn combine_runtime_status( - transport_status: MycRuntimeStatus, - discovery_status: Option<MycRuntimeStatus>, -) -> MycRuntimeStatus { - let mut status = transport_status; - if let Some(discovery_status) = discovery_status { - status = worse_runtime_status(status, discovery_status); - } - status -} - -fn worse_runtime_status(left: MycRuntimeStatus, right: MycRuntimeStatus) -> MycRuntimeStatus { - use MycRuntimeStatus::{Degraded, Healthy, Unready}; - match (left, right) { - (Unready, _) | (_, Unready) => Unready, - (Degraded, _) | (_, Degraded) => Degraded, - _ => Healthy, - } -} - -fn required_available_relays( - snapshot: &MycTransportSnapshot, - configured_relay_count: usize, -) -> Result<usize, MycError> { - match snapshot.delivery_policy { - MycTransportDeliveryPolicy::Any => Ok(1), - MycTransportDeliveryPolicy::All => Ok(configured_relay_count), - MycTransportDeliveryPolicy::Quorum => snapshot.delivery_quorum.ok_or_else(|| { - MycError::InvalidConfig( - "transport.delivery_quorum must be set when transport.delivery_policy is `quorum`" - .to_owned(), - ) - }), - } -} - -async fn probe_relays( - identity: &MycActiveIdentity, - relays: &[RadrootsNostrRelayUrl], - connect_timeout_secs: u64, -) -> Result<Vec<MycRelayProbe>, MycError> { - let relay_count = relays.len(); - if relay_count == 0 { - return Ok(Vec::new()); - } - - let mut pending = relays - .iter() - .cloned() - .enumerate() - .collect::<Vec<_>>() - .into_iter(); - let mut join_set = JoinSet::new(); - let max_concurrency = relay_count.min(MYC_RELAY_PROBE_CONCURRENCY_LIMIT); - - while join_set.len() < max_concurrency { - let Some((relay_index, relay)) = pending.next() else { - break; - }; - let identity = identity.clone(); - join_set.spawn(async move { - let probe = probe_relay(identity, relay.clone(), connect_timeout_secs).await; - (relay_index, probe) - }); - } - - let mut probes = std::iter::repeat_with(|| None) - .take(relay_count) - .collect::<Vec<Option<MycRelayProbe>>>(); - - while let Some(joined) = join_set.join_next().await { - let (relay_index, probe_result) = joined.map_err(|error| { - MycError::InvalidOperation(format!("relay probe task failed: {error}")) - })?; - probes[relay_index] = Some(probe_result?); - while join_set.len() < max_concurrency { - let Some((relay_index, relay)) = pending.next() else { - break; - }; - let identity = identity.clone(); - join_set.spawn(async move { - let probe = probe_relay(identity, relay.clone(), connect_timeout_secs).await; - (relay_index, probe) - }); - } - } - - probes - .into_iter() - .map(|probe| { - probe.ok_or_else(|| MycError::InvalidOperation("missing relay probe result".to_owned())) - }) - .collect() -} - -async fn probe_relay( - identity: MycActiveIdentity, - relay: RadrootsNostrRelayUrl, - connect_timeout_secs: u64, -) -> Result<MycRelayProbe, MycError> { - let relay_url = relay.to_string(); - let client = identity.nostr_client_owned(); - client - .add_relay(relay.as_str()) - .await - .map_err(MycError::from)?; - - match client - .clone() - .into_inner() - .try_connect_relay(relay.as_str(), Duration::from_secs(connect_timeout_secs)) - .await - { - Ok(_) => { - let relays = client.relays().await; - let relay_state = relays.get(&relay).ok_or_else(|| { - MycError::InvalidOperation(format!( - "connected relay `{relay_url}` did not appear in the relay map" - )) - })?; - Ok(MycRelayProbe { - relay_url, - availability: MycRelayProbeAvailability::Available, - relay_status: Some(relay_status_label(relay_state.status())), - connection_attempts: relay_state.stats().attempts(), - successful_connections: relay_state.stats().success(), - latency_ms: relay_state - .stats() - .latency() - .map(|duration| duration.as_millis() as u64), - queue_depth: relay_state.queue(), - error: None, - }) - } - Err(error) => Ok(MycRelayProbe { - relay_url, - availability: MycRelayProbeAvailability::Unavailable, - relay_status: None, - connection_attempts: 0, - successful_connections: 0, - latency_ms: None, - queue_depth: 0, - error: Some(error.to_string()), - }), - } -} - -fn relay_status_label(status: RadrootsNostrRelayStatus) -> String { - status.to_string().to_ascii_lowercase() -} - -fn inspect_signer_state_sqlite_schema(path: &Path) -> MycSqliteSchemaStatusOutput { - inspect_sqlite_schema( - path, - Some("SELECT store_version FROM signer_store_metadata WHERE singleton_id = 1"), - ) -} - -fn inspect_runtime_audit_sqlite_schema(path: &Path) -> MycSqliteSchemaStatusOutput { - inspect_sqlite_schema(path, None) -} - -fn inspect_sqlite_schema( - path: &Path, - store_version_sql: Option<&str>, -) -> MycSqliteSchemaStatusOutput { - let outcome = (|| -> Result<MycSqliteSchemaStatusOutput, String> { - if !path.exists() { - return Err("sqlite persistence file is missing".to_owned()); - } - let executor = SqlxSqliteExecutor::open(path).map_err(|error| error.to_string())?; - let applied_count = query_sqlite_rows::<MycSqliteAppliedCountRow>( - &executor, - "SELECT COUNT(*) AS applied_count FROM __migrations", - )? - .into_iter() - .next() - .ok_or_else(|| "sqlite migrations query returned no rows".to_owned())? - .applied_count; - let latest_migration = query_sqlite_rows::<MycSqliteNamedRow>( - &executor, - "SELECT name FROM __migrations ORDER BY rowid DESC LIMIT 1", - )? - .into_iter() - .next() - .map(|row| row.name); - let journal_mode = - query_sqlite_rows::<MycSqliteJournalModeRow>(&executor, "PRAGMA journal_mode")? - .into_iter() - .next() - .ok_or_else(|| "sqlite journal mode query returned no rows".to_owned())? - .journal_mode; - let store_version = if let Some(sql) = store_version_sql { - query_sqlite_rows::<MycSqliteStoreVersionRow>(&executor, sql)? - .into_iter() - .next() - .map(|row| { - u32::try_from(row.store_version) - .map_err(|_| "sqlite store_version is out of range".to_owned()) - }) - .transpose()? - } else { - None - }; - - Ok(MycSqliteSchemaStatusOutput { - ready: true, - applied_migration_count: Some(applied_count as usize), - latest_migration, - journal_mode: Some(journal_mode), - store_version, - error: None, - }) - })(); - - match outcome { - Ok(output) => output, - Err(error) => MycSqliteSchemaStatusOutput { - ready: false, - applied_migration_count: None, - latest_migration: None, - journal_mode: None, - store_version: None, - error: Some(error), - }, - } -} - -fn query_sqlite_rows<T>(executor: &SqlxSqliteExecutor, sql: &str) -> Result<Vec<T>, String> -where - T: for<'de> Deserialize<'de>, -{ - let raw = executor - .query_raw(sql, "[]") - .map_err(|error| error.to_string())?; - serde_json::from_str(&raw).map_err(|error| error.to_string()) -} - -fn push_counter(lines: &mut Vec<String>, name: &str, value: usize) { - lines.push(format!("{name} {value}")); -} - -fn push_labeled_counter( - lines: &mut Vec<String>, - name: &str, - label_key: &str, - label_value: &str, - value: usize, -) { - lines.push(format!(r#"{name}{{{label_key}="{label_value}"}} {value}"#)); -} - -fn push_outcome_counters(lines: &mut Vec<String>, name: &str, counts: &MycOperationOutcomeCounts) { - push_labeled_counter(lines, name, "outcome", "succeeded", counts.succeeded); - push_labeled_counter(lines, name, "outcome", "rejected", counts.rejected); - push_labeled_counter(lines, name, "outcome", "restored", counts.restored); - push_labeled_counter(lines, name, "outcome", "unavailable", counts.unavailable); - push_labeled_counter(lines, name, "outcome", "missing", counts.missing); - push_labeled_counter(lines, name, "outcome", "matched", counts.matched); - push_labeled_counter(lines, name, "outcome", "drifted", counts.drifted); - push_labeled_counter(lines, name, "outcome", "conflicted", counts.conflicted); - push_labeled_counter(lines, name, "outcome", "skipped", counts.skipped); -} - -fn push_outcome_counters_with_extra_label( - lines: &mut Vec<String>, - name: &str, - extra_label_key: &str, - extra_label_value: &str, - counts: &MycOperationOutcomeCounts, -) { - push_labeled_counter_pair( - lines, - name, - extra_label_key, - extra_label_value, - "outcome", - "succeeded", - counts.succeeded, - ); - push_labeled_counter_pair( - lines, - name, - extra_label_key, - extra_label_value, - "outcome", - "rejected", - counts.rejected, - ); - push_labeled_counter_pair( - lines, - name, - extra_label_key, - extra_label_value, - "outcome", - "restored", - counts.restored, - ); - push_labeled_counter_pair( - lines, - name, - extra_label_key, - extra_label_value, - "outcome", - "unavailable", - counts.unavailable, - ); - push_labeled_counter_pair( - lines, - name, - extra_label_key, - extra_label_value, - "outcome", - "missing", - counts.missing, - ); - push_labeled_counter_pair( - lines, - name, - extra_label_key, - extra_label_value, - "outcome", - "matched", - counts.matched, - ); - push_labeled_counter_pair( - lines, - name, - extra_label_key, - extra_label_value, - "outcome", - "drifted", - counts.drifted, - ); - push_labeled_counter_pair( - lines, - name, - extra_label_key, - extra_label_value, - "outcome", - "conflicted", - counts.conflicted, - ); - push_labeled_counter_pair( - lines, - name, - extra_label_key, - extra_label_value, - "outcome", - "skipped", - counts.skipped, - ); -} - -fn push_labeled_counter_pair( - lines: &mut Vec<String>, - name: &str, - first_key: &str, - first_value: &str, - second_key: &str, - second_value: &str, - value: usize, -) { - lines.push(format!( - r#"{name}{{{first_key}="{first_value}",{second_key}="{second_value}"}} {value}"# - )); -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeMap; - use std::path::Path; - use std::path::PathBuf; - - use crate::host_identity::RadrootsIdentity; - use crate::signer::prelude::{ - RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerConnectionDraft, - RadrootsNostrSignerRequestDecision, - }; - use nostr::PublicKey; - - use super::{ - MYC_SIGNER_STATUS_CONTRACT_VERSION, MycMetricsSnapshot, MycOperationOutcomeCounts, - MycRuntimeStatus, collect_metrics, collect_status_full, collect_status_signer, - inspect_runtime_audit_sqlite_schema, render_metrics_text, worse_runtime_status, - }; - use crate::app::{MycRuntime, MycRuntimePaths}; - use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; - use crate::config::MycRuntimeAuditBackend; - - fn write_test_identity(path: &Path, secret_key: &str) { - let identity = - RadrootsIdentity::from_secret_key_str(secret_key).expect("identity from secret"); - crate::identity_files::store_encrypted_identity(path, &identity).expect("write identity"); - } - - #[test] - fn runtime_status_prefers_the_worst_state() { - assert_eq!( - worse_runtime_status(MycRuntimeStatus::Healthy, MycRuntimeStatus::Degraded), - MycRuntimeStatus::Degraded - ); - assert_eq!( - worse_runtime_status(MycRuntimeStatus::Healthy, MycRuntimeStatus::Unready), - MycRuntimeStatus::Unready - ); - assert_eq!( - worse_runtime_status(MycRuntimeStatus::Degraded, MycRuntimeStatus::Healthy), - MycRuntimeStatus::Degraded - ); - } - - #[test] - fn metrics_text_renderer_is_deterministic() { - let metrics = MycMetricsSnapshot { - signer_request_total: 3, - signer_request_decisions: super::MycAuditDecisionCounts { - allowed: 1, - denied: 1, - challenged: 1, - }, - runtime_operation_total: 2, - runtime_operation_outcomes: MycOperationOutcomeCounts { - succeeded: 1, - rejected: 1, - ..MycOperationOutcomeCounts::default() - }, - runtime_operation_by_kind: BTreeMap::from([( - "listener_response_publish".to_owned(), - MycOperationOutcomeCounts { - succeeded: 1, - ..MycOperationOutcomeCounts::default() - }, - )]), - runtime_aggregate_publish_rejection_count: 1, - runtime_repair_success_count: 0, - runtime_repair_rejection_count: 0, - runtime_unavailable_count: 0, - runtime_replay_restore_count: 0, - delivery_recovery_success_count: 1, - delivery_recovery_rejection_count: 0, - delivery_outbox_total: 2, - delivery_outbox_queued_count: 1, - delivery_outbox_published_pending_finalize_count: 0, - delivery_outbox_failed_count: 1, - delivery_outbox_finalized_count: 0, - delivery_outbox_unfinished_count: 1, - delivery_outbox_critical_unfinished_count: 1, - delivery_outbox_blocked_count: 0, - delivery_outbox_critical_blocked_count: 0, - }; - - let rendered = render_metrics_text(&metrics); - - assert!(rendered.contains("myc_signer_request_total 3")); - assert!(rendered.contains( - r#"myc_runtime_operation_kind_total{kind="listener_response_publish",outcome="succeeded"} 1"# - )); - assert!(rendered.contains("myc_delivery_recovery_success_total 1")); - assert!(rendered.contains("myc_delivery_outbox_total 2")); - } - - #[test] - fn runtime_audit_sqlite_schema_status_reports_missing_file() { - let temp = tempfile::tempdir().expect("tempdir"); - let status = inspect_runtime_audit_sqlite_schema( - MycRuntimePaths::runtime_audit_path_for_backend( - PathBuf::from(temp.path()).as_path(), - MycRuntimeAuditBackend::Sqlite, - ) - .as_path(), - ); - - assert!(!status.ready); - assert_eq!( - status.error.as_deref(), - Some("sqlite persistence file is missing") - ); - } - - #[test] - fn collect_metrics_uses_live_state_after_bootstrap() { - let temp = tempfile::tempdir().expect("tempdir"); - let mut config = crate::config::test_config(temp.path()); - config.paths.signer_identity_path = temp.path().join("signer.json"); - config.paths.user_identity_path = temp.path().join("user.json"); - write_test_identity( - &config.paths.signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_test_identity( - &config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - - let runtime = MycRuntime::bootstrap(config.clone()).expect("runtime"); - let manager = runtime.signer_manager().expect("manager"); - let client_public_key = - PublicKey::parse("7777777777777777777777777777777777777777777777777777777777777777") - .expect("client public key"); - let connection = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new( - client_public_key, - runtime.user_public_identity(), - ) - .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::NotRequired), - ) - .expect("register connection"); - manager - .record_request( - &connection.connection_id, - "req-live-metrics", - radroots_nostr_connect::Method::Ping, - RadrootsNostrSignerRequestDecision::Allowed, - None, - ) - .expect("record request"); - runtime.record_operation_audit(&MycOperationAuditRecord::new( - MycOperationAuditKind::DeliveryRecovery, - MycOperationAuditOutcome::Succeeded, - None, - None, - 1, - 1, - "startup recovery succeeded", - )); - drop(runtime); - - let runtime = MycRuntime::bootstrap(config).expect("runtime restart"); - std::fs::remove_file(&runtime.paths().signer_state_path).expect("remove signer state"); - std::fs::remove_file(&runtime.paths().runtime_audit_path).expect("remove runtime audit"); - - let metrics = collect_metrics(&runtime).expect("collect metrics"); - - assert_eq!(metrics.signer_request_total, 1); - assert_eq!(metrics.signer_request_decisions.allowed, 1); - assert_eq!(metrics.runtime_operation_total, 1); - assert_eq!(metrics.runtime_operation_outcomes.succeeded, 1); - assert_eq!(metrics.delivery_recovery_success_count, 1); - } - - #[tokio::test(flavor = "current_thread")] - async fn status_full_reports_signer_backend_capabilities() { - use crate::signer::prelude::{ - RadrootsNostrSignerBackend, RadrootsNostrSignerConnectionDraft, - }; - - let temp = tempfile::tempdir().expect("tempdir"); - let mut config = crate::config::test_config(temp.path()); - config.paths.signer_identity_path = temp.path().join("signer.json"); - config.paths.user_identity_path = temp.path().join("user.json"); - write_test_identity( - &config.paths.signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_test_identity( - &config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - - let runtime = MycRuntime::bootstrap(config).expect("runtime"); - let backend = runtime.signer_backend(); - let connection = backend - .register_connection(RadrootsNostrSignerConnectionDraft::new( - nostr::Keys::generate().public_key(), - runtime.user_public_identity(), - )) - .expect("register connection"); - - let status = collect_status_full(&runtime).await.expect("status"); - assert!( - status - .signer_backend - .local_signer - .expect("local signer") - .is_secret_backed() - ); - assert_eq!(status.signer_backend.remote_session_count, 1); - assert_eq!(status.signer_backend.remote_sessions.len(), 1); - assert_eq!( - status.signer_backend.remote_sessions[0].connection_id, - connection.connection_id - ); - } - - #[test] - fn status_signer_reports_remote_sessions_without_transport_diagnostics() { - use crate::signer::prelude::RadrootsNostrSignerBackend; - - let temp = tempfile::tempdir().expect("tempdir"); - let mut config = crate::config::test_config(temp.path()); - config.paths.signer_identity_path = temp.path().join("signer.json"); - config.paths.user_identity_path = temp.path().join("user.json"); - config.transport.enabled = true; - config.transport.relays = vec!["ws://127.0.0.1:9".to_owned()]; - config.transport.connect_timeout_secs = 99; - write_test_identity( - &config.paths.signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_test_identity( - &config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - - let runtime = MycRuntime::bootstrap(config).expect("runtime"); - let backend = runtime.signer_backend(); - let connection = backend - .register_connection(RadrootsNostrSignerConnectionDraft::new( - nostr::Keys::generate().public_key(), - runtime.user_public_identity(), - )) - .expect("register connection"); - - let status = collect_status_signer(&runtime).expect("status"); - - assert_eq!( - status.status_contract_version, - MYC_SIGNER_STATUS_CONTRACT_VERSION - ); - assert_eq!(status.status, MycRuntimeStatus::Healthy); - assert!(status.ready); - assert!(status.reasons.is_empty()); - assert_eq!( - status.custody.signer.public_key_hex.as_deref(), - Some("4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa") - ); - assert_eq!( - status.custody.user.public_key_hex.as_deref(), - Some("466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27") - ); - assert!( - status - .signer_backend - .local_signer - .as_ref() - .expect("local signer") - .is_secret_backed() - ); - assert_eq!(status.signer_backend.remote_session_count, 1); - assert_eq!(status.signer_backend.remote_sessions.len(), 1); - assert_eq!( - status.signer_backend.remote_sessions[0].connection_id, - connection.connection_id - ); - } -} diff --git a/src/operability/server.rs b/src/operability/server.rs @@ -1,103 +0,0 @@ -use axum::extract::State; -use axum::http::{HeaderValue, StatusCode, header}; -use axum::response::{IntoResponse, Response}; -use axum::routing::get; -use axum::{Json, Router}; -use tokio::net::TcpListener; - -use crate::app::MycRuntime; -use crate::error::MycError; - -use super::{MycRuntimeStatus, collect_metrics, collect_status_full, render_metrics_text}; - -#[derive(Clone)] -struct MycObservabilityState { - runtime: MycRuntime, -} - -pub async fn run_observability_server<F>(runtime: MycRuntime, shutdown: F) -> Result<(), MycError> -where - F: std::future::Future<Output = ()> + Send + 'static, -{ - let bind_addr = runtime.config().observability.bind_addr; - let listener = TcpListener::bind(bind_addr) - .await - .map_err(|source| MycError::ObservabilityBind { bind_addr, source })?; - let state = MycObservabilityState { runtime }; - let app = Router::new() - .route("/healthz", get(healthz)) - .route("/readyz", get(readyz)) - .route("/status", get(status)) - .route("/metrics", get(metrics)) - .with_state(state); - - tracing::info!(bind_addr = %bind_addr, "observability server listening"); - axum::serve(listener, app) - .with_graceful_shutdown(shutdown) - .await - .map_err(|source| MycError::ObservabilityServe { bind_addr, source }) -} - -async fn healthz(State(state): State<MycObservabilityState>) -> Response { - match collect_status_full(&state.runtime).await { - Ok(status) => { - let code = match status.status { - MycRuntimeStatus::Healthy | MycRuntimeStatus::Degraded => StatusCode::OK, - MycRuntimeStatus::Unready => StatusCode::SERVICE_UNAVAILABLE, - }; - (code, status.status.status_label()).into_response() - } - Err(error) => internal_error_response(error), - } -} - -async fn readyz(State(state): State<MycObservabilityState>) -> Response { - match collect_status_full(&state.runtime).await { - Ok(status) => { - let code = if status.ready { - StatusCode::OK - } else { - StatusCode::SERVICE_UNAVAILABLE - }; - let body = if status.ready { "ready" } else { "unready" }; - (code, body).into_response() - } - Err(error) => internal_error_response(error), - } -} - -async fn status(State(state): State<MycObservabilityState>) -> Response { - match collect_status_full(&state.runtime).await { - Ok(status) => Json(status).into_response(), - Err(error) => internal_error_response(error), - } -} - -async fn metrics(State(state): State<MycObservabilityState>) -> Response { - match collect_metrics(&state.runtime) { - Ok(metrics) => { - let body = render_metrics_text(&metrics); - let mut response = body.into_response(); - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"), - ); - response - } - Err(error) => internal_error_response(error), - } -} - -impl super::MycRuntimeStatus { - fn status_label(self) -> &'static str { - match self { - Self::Healthy => "healthy", - Self::Degraded => "degraded", - Self::Unready => "unready", - } - } -} - -fn internal_error_response(error: MycError) -> Response { - (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response() -} diff --git a/src/outbox.rs b/src/outbox.rs @@ -1,340 +0,0 @@ -use std::fmt; -use std::str::FromStr; -use std::time::{SystemTime, UNIX_EPOCH}; - -use crate::nostr_contract::{RadrootsNostrEvent, RadrootsNostrRelayUrl}; -use crate::signer::prelude::{RadrootsNostrSignerConnectionId, RadrootsNostrSignerWorkflowId}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::error::MycError; - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct MycDeliveryOutboxJobId(String); - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MycDeliveryOutboxKind { - ListenerResponsePublish, - LogoutAcknowledgementPublish, - ConnectAcceptPublish, - AuthReplayPublish, - DiscoveryHandlerPublish, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MycDeliveryOutboxStatus { - Queued, - PublishedPendingFinalize, - Finalized, - Failed, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MycDeliveryOutboxRecord { - pub job_id: MycDeliveryOutboxJobId, - pub kind: MycDeliveryOutboxKind, - pub status: MycDeliveryOutboxStatus, - pub event: RadrootsNostrEvent, - pub relay_urls: Vec<RadrootsNostrRelayUrl>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub connection_id: Option<RadrootsNostrSignerConnectionId>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub request_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub attempt_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub signer_publish_workflow_id: Option<RadrootsNostrSignerWorkflowId>, - pub publish_attempt_count: usize, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_error: Option<String>, - pub created_at_unix: u64, - pub updated_at_unix: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub published_at_unix: Option<u64>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub finalized_at_unix: Option<u64>, -} - -pub trait MycDeliveryOutboxStore: Send + Sync { - fn enqueue(&self, record: &MycDeliveryOutboxRecord) -> Result<(), MycError>; - fn get( - &self, - job_id: &MycDeliveryOutboxJobId, - ) -> Result<Option<MycDeliveryOutboxRecord>, MycError>; - fn list_all(&self) -> Result<Vec<MycDeliveryOutboxRecord>, MycError>; - fn list_by_status( - &self, - status: MycDeliveryOutboxStatus, - ) -> Result<Vec<MycDeliveryOutboxRecord>, MycError>; - fn mark_published_pending_finalize( - &self, - job_id: &MycDeliveryOutboxJobId, - publish_attempt_count: usize, - ) -> Result<MycDeliveryOutboxRecord, MycError>; - fn mark_failed( - &self, - job_id: &MycDeliveryOutboxJobId, - publish_attempt_count: usize, - error: &str, - ) -> Result<MycDeliveryOutboxRecord, MycError>; - fn mark_finalized( - &self, - job_id: &MycDeliveryOutboxJobId, - ) -> Result<MycDeliveryOutboxRecord, MycError>; -} - -impl MycDeliveryOutboxJobId { - pub fn new_v7() -> Self { - Self(Uuid::now_v7().to_string()) - } - - pub fn parse(value: &str) -> Result<Self, MycError> { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(MycError::InvalidDeliveryOutboxJobId(value.to_owned())); - } - Ok(Self(trimmed.to_owned())) - } - - pub fn as_str(&self) -> &str { - self.0.as_str() - } -} - -impl fmt::Display for MycDeliveryOutboxJobId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -impl AsRef<str> for MycDeliveryOutboxJobId { - fn as_ref(&self) -> &str { - self.as_str() - } -} - -impl FromStr for MycDeliveryOutboxJobId { - type Err = MycError; - - fn from_str(value: &str) -> Result<Self, Self::Err> { - Self::parse(value) - } -} - -impl MycDeliveryOutboxRecord { - pub fn new( - kind: MycDeliveryOutboxKind, - event: RadrootsNostrEvent, - relay_urls: Vec<RadrootsNostrRelayUrl>, - ) -> Result<Self, MycError> { - if relay_urls.is_empty() { - return Err(MycError::InvalidOperation( - "delivery outbox job requires at least one relay".to_owned(), - )); - } - let created_at_unix = now_unix_secs(); - Ok(Self { - job_id: MycDeliveryOutboxJobId::new_v7(), - kind, - status: MycDeliveryOutboxStatus::Queued, - event, - relay_urls, - connection_id: None, - request_id: None, - attempt_id: None, - signer_publish_workflow_id: None, - publish_attempt_count: 0, - last_error: None, - created_at_unix, - updated_at_unix: created_at_unix, - published_at_unix: None, - finalized_at_unix: None, - }) - } - - pub fn with_connection_id(mut self, connection_id: &RadrootsNostrSignerConnectionId) -> Self { - self.connection_id = Some(connection_id.clone()); - self - } - - pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self { - self.request_id = Some(request_id.into()); - self - } - - pub fn with_attempt_id(mut self, attempt_id: impl Into<String>) -> Self { - self.attempt_id = Some(attempt_id.into()); - self - } - - pub fn with_signer_publish_workflow_id( - mut self, - workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Self { - self.signer_publish_workflow_id = Some(workflow_id.clone()); - self - } - - pub fn mark_published_pending_finalize( - &mut self, - publish_attempt_count: usize, - updated_at_unix: u64, - ) -> Result<(), MycError> { - match self.status { - MycDeliveryOutboxStatus::Queued | MycDeliveryOutboxStatus::Failed => { - self.status = MycDeliveryOutboxStatus::PublishedPendingFinalize; - self.publish_attempt_count = publish_attempt_count; - self.last_error = None; - self.published_at_unix = Some(updated_at_unix); - self.updated_at_unix = updated_at_unix; - Ok(()) - } - MycDeliveryOutboxStatus::PublishedPendingFinalize => Ok(()), - MycDeliveryOutboxStatus::Finalized => Err(MycError::InvalidOperation( - "cannot mark a finalized delivery outbox job as published".to_owned(), - )), - } - } - - pub fn mark_failed( - &mut self, - publish_attempt_count: usize, - error: impl AsRef<str>, - updated_at_unix: u64, - ) -> Result<(), MycError> { - if self.status == MycDeliveryOutboxStatus::Finalized { - return Err(MycError::InvalidOperation( - "cannot fail a finalized delivery outbox job".to_owned(), - )); - } - let error = error.as_ref().trim(); - if error.is_empty() { - return Err(MycError::InvalidOperation( - "delivery outbox failure reason must not be empty".to_owned(), - )); - } - - self.status = MycDeliveryOutboxStatus::Failed; - self.publish_attempt_count = publish_attempt_count; - self.last_error = Some(error.to_owned()); - self.updated_at_unix = updated_at_unix; - Ok(()) - } - - pub fn mark_finalized(&mut self, updated_at_unix: u64) -> Result<(), MycError> { - if self.status != MycDeliveryOutboxStatus::PublishedPendingFinalize { - return Err(MycError::InvalidOperation( - "cannot finalize a delivery outbox job before publish confirmation".to_owned(), - )); - } - - self.status = MycDeliveryOutboxStatus::Finalized; - self.finalized_at_unix = Some(updated_at_unix); - self.updated_at_unix = updated_at_unix; - Ok(()) - } -} - -pub(crate) fn now_unix_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or(0) -} - -#[cfg(test)] -mod tests { - use crate::host_identity::RadrootsIdentity; - use crate::nostr_contract::{RadrootsNostrGenericEventBuilder, RadrootsNostrKind}; - use crate::signer::prelude::{RadrootsNostrSignerConnectionId, RadrootsNostrSignerWorkflowId}; - - use super::{ - MycDeliveryOutboxJobId, MycDeliveryOutboxKind, MycDeliveryOutboxRecord, - MycDeliveryOutboxStatus, - }; - - fn signed_event() -> nostr::Event { - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - RadrootsNostrGenericEventBuilder::new(RadrootsNostrKind::Custom(24133), "hello") - .sign_with_keys(identity.keys()) - .expect("sign event") - } - - #[test] - fn delivery_outbox_job_ids_parse_and_display() { - let job_id = MycDeliveryOutboxJobId::parse("job-1").expect("job id"); - assert_eq!(job_id.as_str(), "job-1"); - assert_eq!(job_id.to_string(), "job-1"); - assert_eq!(job_id.as_ref(), "job-1"); - assert!(MycDeliveryOutboxJobId::parse(" ").is_err()); - assert!(!MycDeliveryOutboxJobId::new_v7().as_str().is_empty()); - } - - #[test] - fn delivery_outbox_record_covers_state_transitions() { - let connection_id = RadrootsNostrSignerConnectionId::parse("conn-outbox").expect("id"); - let workflow_id = RadrootsNostrSignerWorkflowId::parse("wf-outbox").expect("id"); - let mut record = MycDeliveryOutboxRecord::new( - MycDeliveryOutboxKind::AuthReplayPublish, - signed_event(), - vec!["wss://relay.example.com".parse().expect("relay")], - ) - .expect("record") - .with_connection_id(&connection_id) - .with_request_id("req-1") - .with_attempt_id("attempt-1") - .with_signer_publish_workflow_id(&workflow_id); - - assert_eq!(record.status, MycDeliveryOutboxStatus::Queued); - assert_eq!(record.connection_id.as_ref(), Some(&connection_id)); - assert_eq!(record.request_id.as_deref(), Some("req-1")); - assert_eq!(record.attempt_id.as_deref(), Some("attempt-1")); - assert_eq!( - record.signer_publish_workflow_id.as_ref(), - Some(&workflow_id) - ); - - record - .mark_published_pending_finalize(1, 100) - .expect("mark published"); - assert_eq!( - record.status, - MycDeliveryOutboxStatus::PublishedPendingFinalize - ); - assert_eq!(record.publish_attempt_count, 1); - assert_eq!(record.published_at_unix, Some(100)); - - record - .mark_failed(2, "relay rejected", 101) - .expect("mark failed"); - assert_eq!(record.status, MycDeliveryOutboxStatus::Failed); - assert_eq!(record.last_error.as_deref(), Some("relay rejected")); - - record - .mark_published_pending_finalize(3, 102) - .expect("republish"); - record.mark_finalized(103).expect("finalize"); - assert_eq!(record.status, MycDeliveryOutboxStatus::Finalized); - assert_eq!(record.finalized_at_unix, Some(103)); - assert!(record.mark_failed(4, "late failure", 104).is_err()); - } - - #[test] - fn delivery_outbox_record_requires_relays() { - let err = MycDeliveryOutboxRecord::new( - MycDeliveryOutboxKind::ListenerResponsePublish, - signed_event(), - Vec::new(), - ) - .expect_err("missing relays"); - assert!( - err.to_string() - .contains("delivery outbox job requires at least one relay") - ); - } -} diff --git a/src/outbox_sqlite.rs b/src/outbox_sqlite.rs @@ -1,601 +0,0 @@ -use std::path::{Path, PathBuf}; - -use crate::sql::migrations::{Migration, migrations_run_all_up}; -use crate::sql::{SqlExecutor, SqlxSqliteExecutor}; -use serde::Deserialize; -use serde::de::DeserializeOwned; -use serde_json::{Value, json}; - -use crate::error::MycError; -use crate::outbox::{ - MycDeliveryOutboxJobId, MycDeliveryOutboxKind, MycDeliveryOutboxRecord, - MycDeliveryOutboxStatus, MycDeliveryOutboxStore, now_unix_secs, -}; - -const MYC_DELIVERY_OUTBOX_SQLITE_FILE_NAME: &str = "delivery-outbox.sqlite"; -#[cfg(test)] -const MYC_DELIVERY_OUTBOX_MEMORY_PATH: &str = ":memory:"; - -static MYC_DELIVERY_OUTBOX_MIGRATIONS: &[Migration] = &[Migration { - name: "0000_delivery_outbox_init", - up_sql: include_str!("../migrations/0000_delivery_outbox_init.up.sql"), - down_sql: include_str!("../migrations/0000_delivery_outbox_init.down.sql"), -}]; - -/// Myc keeps its delivery outbox store local to the service boundary. -pub struct MycSqliteDeliveryOutboxStore { - db: MycDeliveryOutboxSqliteDb, -} - -struct MycDeliveryOutboxSqliteDb { - path: PathBuf, - executor: SqlxSqliteExecutor, - file_backed: bool, -} - -#[derive(Debug, Deserialize)] -struct MycDeliveryOutboxRow { - job_id: String, - kind: String, - status: String, - event_json: String, - relay_urls_json: String, - connection_id: Option<String>, - request_id: Option<String>, - attempt_id: Option<String>, - signer_publish_workflow_id: Option<String>, - publish_attempt_count: i64, - last_error: Option<String>, - created_at_unix: u64, - updated_at_unix: u64, - published_at_unix: Option<u64>, - finalized_at_unix: Option<u64>, -} - -impl MycSqliteDeliveryOutboxStore { - pub fn open(state_dir: impl AsRef<Path>) -> Result<Self, MycError> { - let db = MycDeliveryOutboxSqliteDb::open( - state_dir - .as_ref() - .join(MYC_DELIVERY_OUTBOX_SQLITE_FILE_NAME), - )?; - Ok(Self { db }) - } - - #[cfg(test)] - pub fn open_memory() -> Result<Self, MycError> { - Ok(Self { - db: MycDeliveryOutboxSqliteDb::open_memory()?, - }) - } - - pub fn path(&self) -> &Path { - self.db.path() - } - - fn update_record( - &self, - job_id: &MycDeliveryOutboxJobId, - update: impl FnOnce(&mut MycDeliveryOutboxRecord) -> Result<(), MycError>, - ) -> Result<MycDeliveryOutboxRecord, MycError> { - let mut record = self - .get(job_id)? - .ok_or_else(|| MycError::DeliveryOutboxJobNotFound(job_id.to_string()))?; - update(&mut record)?; - exec_json( - self.db.path(), - self.db.executor(), - "UPDATE myc_delivery_outbox SET kind = ?, status = ?, event_json = ?, relay_urls_json = ?, connection_id = ?, request_id = ?, attempt_id = ?, signer_publish_workflow_id = ?, publish_attempt_count = ?, last_error = ?, created_at_unix = ?, updated_at_unix = ?, published_at_unix = ?, finalized_at_unix = ? WHERE job_id = ?", - serialize_record_update_params(self.db.path(), &record, job_id.as_str())?, - )?; - Ok(record) - } -} - -impl MycDeliveryOutboxStore for MycSqliteDeliveryOutboxStore { - fn enqueue(&self, record: &MycDeliveryOutboxRecord) -> Result<(), MycError> { - exec_json( - self.db.path(), - self.db.executor(), - "INSERT INTO myc_delivery_outbox(job_id, kind, status, event_json, relay_urls_json, connection_id, request_id, attempt_id, signer_publish_workflow_id, publish_attempt_count, last_error, created_at_unix, updated_at_unix, published_at_unix, finalized_at_unix) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - serialize_record_params(self.db.path(), record)?, - ) - } - - fn get( - &self, - job_id: &MycDeliveryOutboxJobId, - ) -> Result<Option<MycDeliveryOutboxRecord>, MycError> { - let rows: Vec<MycDeliveryOutboxRow> = query_rows( - self.db.path(), - self.db.executor(), - "SELECT job_id, kind, status, event_json, relay_urls_json, connection_id, request_id, attempt_id, signer_publish_workflow_id, publish_attempt_count, last_error, created_at_unix, updated_at_unix, published_at_unix, finalized_at_unix FROM myc_delivery_outbox WHERE job_id = ? LIMIT 1", - json!([job_id.as_str()]), - )?; - rows.into_iter() - .next() - .map(|row| row.into_record(self.db.path())) - .transpose() - } - - fn list_all(&self) -> Result<Vec<MycDeliveryOutboxRecord>, MycError> { - let rows: Vec<MycDeliveryOutboxRow> = query_rows( - self.db.path(), - self.db.executor(), - "SELECT job_id, kind, status, event_json, relay_urls_json, connection_id, request_id, attempt_id, signer_publish_workflow_id, publish_attempt_count, last_error, created_at_unix, updated_at_unix, published_at_unix, finalized_at_unix FROM myc_delivery_outbox ORDER BY created_at_unix ASC, job_id ASC", - json!([]), - )?; - rows.into_iter() - .map(|row| row.into_record(self.db.path())) - .collect() - } - - fn list_by_status( - &self, - status: MycDeliveryOutboxStatus, - ) -> Result<Vec<MycDeliveryOutboxRecord>, MycError> { - let rows: Vec<MycDeliveryOutboxRow> = query_rows( - self.db.path(), - self.db.executor(), - "SELECT job_id, kind, status, event_json, relay_urls_json, connection_id, request_id, attempt_id, signer_publish_workflow_id, publish_attempt_count, last_error, created_at_unix, updated_at_unix, published_at_unix, finalized_at_unix FROM myc_delivery_outbox WHERE status = ? ORDER BY created_at_unix ASC, job_id ASC", - json!([status_label(status)]), - )?; - rows.into_iter() - .map(|row| row.into_record(self.db.path())) - .collect() - } - - fn mark_published_pending_finalize( - &self, - job_id: &MycDeliveryOutboxJobId, - publish_attempt_count: usize, - ) -> Result<MycDeliveryOutboxRecord, MycError> { - self.update_record(job_id, |record| { - record.mark_published_pending_finalize(publish_attempt_count, now_unix_secs()) - }) - } - - fn mark_failed( - &self, - job_id: &MycDeliveryOutboxJobId, - publish_attempt_count: usize, - error: &str, - ) -> Result<MycDeliveryOutboxRecord, MycError> { - self.update_record(job_id, |record| { - record.mark_failed(publish_attempt_count, error, now_unix_secs()) - }) - } - - fn mark_finalized( - &self, - job_id: &MycDeliveryOutboxJobId, - ) -> Result<MycDeliveryOutboxRecord, MycError> { - self.update_record(job_id, |record| record.mark_finalized(now_unix_secs())) - } -} - -impl MycDeliveryOutboxRow { - fn into_record(self, path: &Path) -> Result<MycDeliveryOutboxRecord, MycError> { - Ok(MycDeliveryOutboxRecord { - job_id: self.job_id.parse()?, - kind: parse_kind(self.kind.as_str())?, - status: parse_status(self.status.as_str())?, - event: parse_json_field(path, self.event_json.as_str(), "event_json")?, - relay_urls: parse_json_field(path, self.relay_urls_json.as_str(), "relay_urls_json")?, - connection_id: self.connection_id.as_deref().map(str::parse).transpose()?, - request_id: self.request_id, - attempt_id: self.attempt_id, - signer_publish_workflow_id: self - .signer_publish_workflow_id - .as_deref() - .map(str::parse) - .transpose()?, - publish_attempt_count: usize_from_i64( - path, - self.publish_attempt_count, - "publish_attempt_count", - )?, - last_error: self.last_error, - created_at_unix: self.created_at_unix, - updated_at_unix: self.updated_at_unix, - published_at_unix: self.published_at_unix, - finalized_at_unix: self.finalized_at_unix, - }) - } -} - -impl MycDeliveryOutboxSqliteDb { - fn open(path: PathBuf) -> Result<Self, MycError> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|source| MycError::CreateDir { - path: parent.to_path_buf(), - source, - })?; - } - let executor = SqlxSqliteExecutor::open(path.as_path()).map_err(|source| { - MycError::DeliveryOutboxSql { - path: path.clone(), - source, - } - })?; - let db = Self { - path, - executor, - file_backed: true, - }; - db.configure()?; - db.run_migrations()?; - Ok(db) - } - - #[cfg(test)] - fn open_memory() -> Result<Self, MycError> { - let executor = - SqlxSqliteExecutor::open_memory().map_err(|source| MycError::DeliveryOutboxSql { - path: PathBuf::from(MYC_DELIVERY_OUTBOX_MEMORY_PATH), - source, - })?; - let db = Self { - path: PathBuf::from(MYC_DELIVERY_OUTBOX_MEMORY_PATH), - executor, - file_backed: false, - }; - db.configure()?; - db.run_migrations()?; - Ok(db) - } - - fn path(&self) -> &Path { - self.path.as_path() - } - - fn executor(&self) -> &SqlxSqliteExecutor { - &self.executor - } - - fn configure(&self) -> Result<(), MycError> { - exec_json( - self.path(), - self.executor(), - "PRAGMA foreign_keys = ON", - json!([]), - )?; - if self.file_backed { - exec_json( - self.path(), - self.executor(), - "PRAGMA journal_mode = WAL", - json!([]), - )?; - } - Ok(()) - } - - fn run_migrations(&self) -> Result<(), MycError> { - migrations_run_all_up(self.executor(), MYC_DELIVERY_OUTBOX_MIGRATIONS).map_err(|source| { - MycError::DeliveryOutboxSql { - path: self.path.clone(), - source, - } - }) - } -} - -fn serialize_record_params( - path: &Path, - record: &MycDeliveryOutboxRecord, -) -> Result<Value, MycError> { - Ok(Value::Array(vec![ - Value::from(record.job_id.as_str()), - Value::from(kind_label(record.kind)), - Value::from(status_label(record.status)), - Value::from(serialize_json_field(path, &record.event)?), - Value::from(serialize_json_field(path, &record.relay_urls)?), - record - .connection_id - .as_ref() - .map(|value| Value::from(value.as_str())) - .unwrap_or(Value::Null), - record - .request_id - .clone() - .map(Value::from) - .unwrap_or(Value::Null), - record - .attempt_id - .clone() - .map(Value::from) - .unwrap_or(Value::Null), - record - .signer_publish_workflow_id - .as_ref() - .map(|value| Value::from(value.as_str())) - .unwrap_or(Value::Null), - Value::from(i64::try_from(record.publish_attempt_count).map_err(|_| { - MycError::InvalidOperation( - "delivery outbox publish_attempt_count exceeds sqlite range".to_owned(), - ) - })?), - record - .last_error - .clone() - .map(Value::from) - .unwrap_or(Value::Null), - Value::from(record.created_at_unix), - Value::from(record.updated_at_unix), - record - .published_at_unix - .map(Value::from) - .unwrap_or(Value::Null), - record - .finalized_at_unix - .map(Value::from) - .unwrap_or(Value::Null), - ])) -} - -fn serialize_record_update_params( - path: &Path, - record: &MycDeliveryOutboxRecord, - trailing_job_id: &str, -) -> Result<Value, MycError> { - Ok(Value::Array(vec![ - Value::from(kind_label(record.kind)), - Value::from(status_label(record.status)), - Value::from(serialize_json_field(path, &record.event)?), - Value::from(serialize_json_field(path, &record.relay_urls)?), - record - .connection_id - .as_ref() - .map(|value| Value::from(value.as_str())) - .unwrap_or(Value::Null), - record - .request_id - .clone() - .map(Value::from) - .unwrap_or(Value::Null), - record - .attempt_id - .clone() - .map(Value::from) - .unwrap_or(Value::Null), - record - .signer_publish_workflow_id - .as_ref() - .map(|value| Value::from(value.as_str())) - .unwrap_or(Value::Null), - Value::from(i64::try_from(record.publish_attempt_count).map_err(|_| { - MycError::InvalidOperation( - "delivery outbox publish_attempt_count exceeds sqlite range".to_owned(), - ) - })?), - record - .last_error - .clone() - .map(Value::from) - .unwrap_or(Value::Null), - Value::from(record.created_at_unix), - Value::from(record.updated_at_unix), - record - .published_at_unix - .map(Value::from) - .unwrap_or(Value::Null), - record - .finalized_at_unix - .map(Value::from) - .unwrap_or(Value::Null), - Value::from(trailing_job_id), - ])) -} - -fn exec_json( - path: &Path, - executor: &impl SqlExecutor, - sql: &str, - params: Value, -) -> Result<(), MycError> { - executor - .exec(sql, params.to_string().as_str()) - .map_err(|source| MycError::DeliveryOutboxSql { - path: path.to_path_buf(), - source, - })?; - Ok(()) -} - -fn query_rows<T: DeserializeOwned>( - path: &Path, - executor: &impl SqlExecutor, - sql: &str, - params: Value, -) -> Result<Vec<T>, MycError> { - let raw = executor - .query_raw(sql, params.to_string().as_str()) - .map_err(|source| MycError::DeliveryOutboxSql { - path: path.to_path_buf(), - source, - })?; - serde_json::from_str(&raw).map_err(|source| MycError::DeliveryOutboxSqlDecode { - path: path.to_path_buf(), - source, - }) -} - -fn serialize_json_field(path: &Path, value: &impl serde::Serialize) -> Result<String, MycError> { - serde_json::to_string(value).map_err(|source| MycError::DeliveryOutboxSerialize { - path: path.to_path_buf(), - source, - }) -} - -fn parse_json_field<T: DeserializeOwned>( - path: &Path, - value: &str, - _field: &str, -) -> Result<T, MycError> { - serde_json::from_str(value).map_err(|source| MycError::DeliveryOutboxSqlDecode { - path: path.to_path_buf(), - source, - }) -} - -fn kind_label(kind: MycDeliveryOutboxKind) -> &'static str { - match kind { - MycDeliveryOutboxKind::ListenerResponsePublish => "listener_response_publish", - MycDeliveryOutboxKind::LogoutAcknowledgementPublish => "logout_acknowledgement_publish", - MycDeliveryOutboxKind::ConnectAcceptPublish => "connect_accept_publish", - MycDeliveryOutboxKind::AuthReplayPublish => "auth_replay_publish", - MycDeliveryOutboxKind::DiscoveryHandlerPublish => "discovery_handler_publish", - } -} - -fn parse_kind(value: &str) -> Result<MycDeliveryOutboxKind, MycError> { - match value { - "listener_response_publish" => Ok(MycDeliveryOutboxKind::ListenerResponsePublish), - "logout_acknowledgement_publish" => Ok(MycDeliveryOutboxKind::LogoutAcknowledgementPublish), - "connect_accept_publish" => Ok(MycDeliveryOutboxKind::ConnectAcceptPublish), - "auth_replay_publish" => Ok(MycDeliveryOutboxKind::AuthReplayPublish), - "discovery_handler_publish" => Ok(MycDeliveryOutboxKind::DiscoveryHandlerPublish), - other => Err(MycError::InvalidOperation(format!( - "unknown delivery outbox kind `{other}`" - ))), - } -} - -fn status_label(status: MycDeliveryOutboxStatus) -> &'static str { - match status { - MycDeliveryOutboxStatus::Queued => "queued", - MycDeliveryOutboxStatus::PublishedPendingFinalize => "published_pending_finalize", - MycDeliveryOutboxStatus::Finalized => "finalized", - MycDeliveryOutboxStatus::Failed => "failed", - } -} - -fn parse_status(value: &str) -> Result<MycDeliveryOutboxStatus, MycError> { - match value { - "queued" => Ok(MycDeliveryOutboxStatus::Queued), - "published_pending_finalize" => Ok(MycDeliveryOutboxStatus::PublishedPendingFinalize), - "finalized" => Ok(MycDeliveryOutboxStatus::Finalized), - "failed" => Ok(MycDeliveryOutboxStatus::Failed), - other => Err(MycError::InvalidOperation(format!( - "unknown delivery outbox status `{other}`" - ))), - } -} - -fn usize_from_i64(path: &Path, value: i64, field: &str) -> Result<usize, MycError> { - usize::try_from(value).map_err(|_| { - MycError::InvalidOperation(format!( - "delivery outbox field `{field}` at {} is out of range for usize", - path.display() - )) - }) -} - -#[cfg(test)] -mod tests { - use crate::host_identity::RadrootsIdentity; - use crate::nostr_contract::{RadrootsNostrGenericEventBuilder, RadrootsNostrKind}; - use crate::signer::prelude::{RadrootsNostrSignerConnectionId, RadrootsNostrSignerWorkflowId}; - - use crate::outbox::{ - MycDeliveryOutboxKind, MycDeliveryOutboxRecord, MycDeliveryOutboxStatus, - MycDeliveryOutboxStore, - }; - - use super::MycSqliteDeliveryOutboxStore; - - fn sample_record() -> MycDeliveryOutboxRecord { - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - let event = - RadrootsNostrGenericEventBuilder::new(RadrootsNostrKind::Custom(24133), "hello") - .sign_with_keys(identity.keys()) - .expect("sign event"); - MycDeliveryOutboxRecord::new( - MycDeliveryOutboxKind::AuthReplayPublish, - event, - vec!["wss://relay.example.com".parse().expect("relay")], - ) - .expect("record") - .with_connection_id( - &RadrootsNostrSignerConnectionId::parse("conn-sqlite-outbox").expect("id"), - ) - .with_request_id("req-sqlite-outbox") - .with_attempt_id("attempt-sqlite-outbox") - .with_signer_publish_workflow_id( - &RadrootsNostrSignerWorkflowId::parse("wf-sqlite-outbox").expect("id"), - ) - } - - #[test] - fn sqlite_outbox_store_round_trips_and_updates_status() { - let store = MycSqliteDeliveryOutboxStore::open_memory().expect("open store"); - let record = sample_record(); - - store.enqueue(&record).expect("enqueue"); - assert_eq!( - store.get(&record.job_id).expect("get"), - Some(record.clone()) - ); - assert_eq!(store.list_all().expect("list all"), vec![record.clone()]); - assert_eq!( - store - .list_by_status(MycDeliveryOutboxStatus::Queued) - .expect("list queued"), - vec![record.clone()] - ); - - let published = store - .mark_published_pending_finalize(&record.job_id, 1) - .expect("mark published"); - assert_eq!( - published.status, - MycDeliveryOutboxStatus::PublishedPendingFinalize - ); - assert_eq!(published.publish_attempt_count, 1); - - let failed = store - .mark_failed(&record.job_id, 2, "relay rejected") - .expect("mark failed"); - assert_eq!(failed.status, MycDeliveryOutboxStatus::Failed); - assert_eq!(failed.last_error.as_deref(), Some("relay rejected")); - - let republished = store - .mark_published_pending_finalize(&record.job_id, 3) - .expect("republish"); - assert_eq!( - republished.status, - MycDeliveryOutboxStatus::PublishedPendingFinalize - ); - - let finalized = store - .mark_finalized(&record.job_id) - .expect("mark finalized"); - assert_eq!(finalized.status, MycDeliveryOutboxStatus::Finalized); - assert_eq!( - store - .list_by_status(MycDeliveryOutboxStatus::Finalized) - .expect("list finalized"), - vec![finalized] - ); - } - - #[test] - fn sqlite_outbox_store_reopens_file_backed_state() { - let temp = tempfile::tempdir().expect("tempdir"); - let record = sample_record(); - - let store = MycSqliteDeliveryOutboxStore::open(temp.path()).expect("open store"); - store.enqueue(&record).expect("enqueue"); - - let reopened = MycSqliteDeliveryOutboxStore::open(temp.path()).expect("reopen store"); - assert_eq!( - reopened.get(&record.job_id).expect("get reopened"), - Some(record) - ); - assert!(reopened.path().ends_with("delivery-outbox.sqlite")); - } -} diff --git a/src/paths.rs b/src/paths.rs @@ -1,176 +0,0 @@ -use core::fmt; -use std::path::{Path, PathBuf}; - -use crate::{ - config::{MycIdentityBackend, MycIdentitySourceSpec}, - runtime_context::MycRuntimeContext, -}; - -const DEFAULT_SIGNER_IDENTITY_FILE_NAME: &str = "signer-identity.json"; -const DEFAULT_USER_IDENTITY_FILE_NAME: &str = "user-identity.json"; - -/// Transitional prototype runtime inputs derived from a sealed Myc context. -/// -/// The fields are crate-private so external callers cannot replace canonical -/// service-instance paths independently of the typed runtime context. Later -/// ordered state/provider checkpoints remove the remaining prototype provider -/// fields rather than promoting them into the hardened configuration contract. -/// -/// ```compile_fail -/// use myc::MycPathsConfig; -/// -/// let _ = MycPathsConfig { -/// runtime_context: todo!(), -/// signer_identity_backend: todo!(), -/// signer_identity_path: todo!(), -/// signer_identity_keyring_account_id: None, -/// signer_identity_keyring_service_name: String::new(), -/// signer_identity_profile_path: None, -/// user_identity_backend: todo!(), -/// user_identity_path: todo!(), -/// user_identity_keyring_account_id: None, -/// user_identity_keyring_service_name: String::new(), -/// user_identity_profile_path: None, -/// }; -/// ``` -#[derive(Clone, PartialEq, Eq)] -pub struct MycPathsConfig { - pub(crate) runtime_context: MycRuntimeContext, - pub(crate) signer_identity_backend: MycIdentityBackend, - pub(crate) signer_identity_path: PathBuf, - pub(crate) signer_identity_keyring_account_id: Option<String>, - pub(crate) signer_identity_keyring_service_name: String, - pub(crate) signer_identity_profile_path: Option<PathBuf>, - pub(crate) user_identity_backend: MycIdentityBackend, - pub(crate) user_identity_path: PathBuf, - pub(crate) user_identity_keyring_account_id: Option<String>, - pub(crate) user_identity_keyring_service_name: String, - pub(crate) user_identity_profile_path: Option<PathBuf>, -} - -impl fmt::Debug for MycPathsConfig { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("MycPathsConfig([redacted])") - } -} - -impl MycPathsConfig { - pub(crate) fn from_runtime_context(runtime_context: MycRuntimeContext) -> Self { - let secrets = runtime_context.context().paths().secrets(); - let signer_identity_path = secrets.join(DEFAULT_SIGNER_IDENTITY_FILE_NAME); - let user_identity_path = secrets.join(DEFAULT_USER_IDENTITY_FILE_NAME); - Self { - runtime_context, - signer_identity_backend: MycIdentityBackend::EncryptedFile, - signer_identity_path, - signer_identity_keyring_account_id: None, - signer_identity_keyring_service_name: "org.radroots.myc.signer".to_owned(), - signer_identity_profile_path: None, - user_identity_backend: MycIdentityBackend::EncryptedFile, - user_identity_path, - user_identity_keyring_account_id: None, - user_identity_keyring_service_name: "org.radroots.myc.user".to_owned(), - user_identity_profile_path: None, - } - } - - #[must_use] - pub fn runtime_context(&self) -> &MycRuntimeContext { - &self.runtime_context - } - - #[must_use] - pub fn state_dir(&self) -> &Path { - self.runtime_context.context().paths().state() - } - - #[must_use] - pub fn run_dir(&self) -> &Path { - self.runtime_context.context().paths().run() - } - - #[must_use] - pub fn logs_dir(&self) -> &Path { - self.runtime_context.context().paths().logs() - } - - #[must_use] - pub fn signer_identity_path(&self) -> &Path { - &self.signer_identity_path - } - - #[must_use] - pub fn user_identity_path(&self) -> &Path { - &self.user_identity_path - } - - pub fn signer_identity_source(&self) -> MycIdentitySourceSpec { - MycIdentitySourceSpec { - backend: self.signer_identity_backend, - path: match self.signer_identity_backend { - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ManagedAccount - | MycIdentityBackend::ExternalCommand => Some(self.signer_identity_path.clone()), - MycIdentityBackend::HostVault => None, - }, - keyring_account_id: match self.signer_identity_backend { - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ManagedAccount - | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::HostVault => self.signer_identity_keyring_account_id.clone(), - }, - keyring_service_name: match self.signer_identity_backend { - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::HostVault | MycIdentityBackend::ManagedAccount => { - Some(self.signer_identity_keyring_service_name.clone()) - } - }, - profile_path: match self.signer_identity_backend { - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ManagedAccount - | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::HostVault => self.signer_identity_profile_path.clone(), - }, - } - } - - pub fn user_identity_source(&self) -> MycIdentitySourceSpec { - MycIdentitySourceSpec { - backend: self.user_identity_backend, - path: match self.user_identity_backend { - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ManagedAccount - | MycIdentityBackend::ExternalCommand => Some(self.user_identity_path.clone()), - MycIdentityBackend::HostVault => None, - }, - keyring_account_id: match self.user_identity_backend { - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ManagedAccount - | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::HostVault => self.user_identity_keyring_account_id.clone(), - }, - keyring_service_name: match self.user_identity_backend { - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::HostVault | MycIdentityBackend::ManagedAccount => { - Some(self.user_identity_keyring_service_name.clone()) - } - }, - profile_path: match self.user_identity_backend { - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ManagedAccount - | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::HostVault => self.user_identity_profile_path.clone(), - }, - } - } -} diff --git a/src/persistence.rs b/src/persistence.rs @@ -1,2014 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet}; -use std::fs; -use std::path::{Component, Path, PathBuf}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use crate::signer::prelude::{ - RadrootsNostrFileSignerStore, RadrootsNostrSignerAuthState, - RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerPublishWorkflowKind, - RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerPublishWorkflowState, - RadrootsNostrSignerStore, RadrootsNostrSignerStoreState, RadrootsNostrSqliteSignerStore, -}; -use nostr::PublicKey; -use serde::{Deserialize, Serialize}; - -use crate::app::MycRuntimePaths; -use crate::audit::MycJsonlOperationAuditStore; -use crate::audit_sqlite::MycSqliteOperationAuditStore; -use crate::config::{ - MycConfig, MycIdentityBackend, MycIdentitySourceSpec, MycRuntimeAuditBackend, - MycSignerStateBackend, -}; -use crate::custody::MycIdentityProvider; -use crate::error::MycError; -use crate::identity_files::encrypted_identity_wrapping_key_path; -use crate::outbox::{ - MycDeliveryOutboxKind, MycDeliveryOutboxRecord, MycDeliveryOutboxStatus, MycDeliveryOutboxStore, -}; -use crate::outbox_sqlite::MycSqliteDeliveryOutboxStore; - -const MYC_PERSISTENCE_BACKUP_MANIFEST_VERSION: u32 = 1; -const MYC_PERSISTENCE_BACKUP_MANIFEST_FILE_NAME: &str = "manifest.json"; -const MYC_PERSISTENCE_BACKUP_STATE_DIR_NAME: &str = "state"; -const MYC_PERSISTENCE_BACKUP_IDENTITIES_DIR_NAME: &str = "identity-references"; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct MycPersistenceImportSelection { - import_signer_state: bool, - import_runtime_audit: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycPersistenceImportJsonToSqliteOutput { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub signer_state: Option<MycSignerStateImportOutput>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub runtime_audit: Option<MycRuntimeAuditImportOutput>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycSignerStateImportOutput { - pub source_path: PathBuf, - pub destination_path: PathBuf, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub signer_identity_id: Option<String>, - pub connection_count: usize, - pub request_audit_count: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycRuntimeAuditImportOutput { - pub source_dir: PathBuf, - pub destination_path: PathBuf, - pub record_count: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycPersistenceVerifyRestoreOutput { - pub signer_identity_id: String, - pub user_identity_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub discovery_app_identity_id: Option<String>, - pub signer_state: MycSignerStateVerifyRestoreOutput, - pub runtime_audit: MycRuntimeAuditVerifyRestoreOutput, - pub delivery_outbox: MycDeliveryOutboxVerifyRestoreOutput, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycPersistenceBackupOutput { - pub backup_dir: PathBuf, - pub manifest_path: PathBuf, - pub state_dir: MycPersistenceBackupStateOutput, - pub signer_identity_reference: MycPersistenceIdentityReferenceBackupOutput, - pub user_identity_reference: MycPersistenceIdentityReferenceBackupOutput, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub discovery_app_identity_reference: Option<MycPersistenceIdentityReferenceBackupOutput>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycPersistenceBackupStateOutput { - pub source_path: PathBuf, - pub destination_path: PathBuf, - pub file_count: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycPersistenceRestoreOutput { - pub backup_dir: PathBuf, - pub manifest_path: PathBuf, - pub state_dir: MycPersistenceRestoreStateOutput, - pub signer_identity_reference: MycPersistenceIdentityReferenceRestoreOutput, - pub user_identity_reference: MycPersistenceIdentityReferenceRestoreOutput, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub discovery_app_identity_reference: Option<MycPersistenceIdentityReferenceRestoreOutput>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycPersistenceRestoreStateOutput { - pub source_path: PathBuf, - pub destination_path: PathBuf, - pub file_count: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycPersistenceIdentityReferenceBackupOutput { - pub role: String, - pub backend: MycIdentityBackend, - pub copied_file_count: usize, - pub copied_files: Vec<PathBuf>, - pub contains_secret_material: bool, - pub requires_out_of_backup_dependencies: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycPersistenceIdentityReferenceRestoreOutput { - pub role: String, - pub backend: MycIdentityBackend, - pub restored_file_count: usize, - pub restored_files: Vec<PathBuf>, - pub contains_secret_material: bool, - pub requires_out_of_backup_dependencies: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycSignerStateVerifyRestoreOutput { - pub backend: MycSignerStateBackend, - pub path: PathBuf, - pub connection_count: usize, - pub request_audit_count: usize, - pub publish_workflow_count: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycRuntimeAuditVerifyRestoreOutput { - pub backend: MycRuntimeAuditBackend, - pub path: PathBuf, - pub record_count: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycDeliveryOutboxVerifyRestoreOutput { - pub path: PathBuf, - pub total_job_count: usize, - pub queued_job_count: usize, - pub published_pending_finalize_job_count: usize, - pub finalized_job_count: usize, - pub failed_job_count: usize, - pub unfinished_job_count: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct MycPersistenceBackupManifest { - version: u32, - created_at_unix: u64, - signer_state_backend: MycSignerStateBackend, - runtime_audit_backend: MycRuntimeAuditBackend, - state_dir: MycPersistenceBackupStateManifest, - signer_identity_reference: MycPersistenceIdentityReferenceManifest, - user_identity_reference: MycPersistenceIdentityReferenceManifest, - #[serde(default, skip_serializing_if = "Option::is_none")] - discovery_app_identity_reference: Option<MycPersistenceIdentityReferenceManifest>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct MycPersistenceBackupStateManifest { - relative_path: PathBuf, - files: Vec<PathBuf>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct MycPersistenceIdentityReferenceManifest { - role: String, - source: MycIdentitySourceSpec, - files: Vec<MycPersistenceIdentityReferenceFileManifest>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct MycPersistenceIdentityReferenceFileManifest { - field: MycPersistenceIdentityReferenceField, - relative_path: PathBuf, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -enum MycPersistenceIdentityReferenceField { - Path, - EncryptedKeyPath, - ProfilePath, -} - -impl MycPersistenceImportSelection { - pub fn new(import_signer_state: bool, import_runtime_audit: bool) -> Self { - Self { - import_signer_state, - import_runtime_audit, - } - } - - fn resolve(self, config: &MycConfig) -> Result<Self, MycError> { - let import_signer_state = if self.import_signer_state || self.import_runtime_audit { - self.import_signer_state - } else { - config.persistence.signer_state_backend == MycSignerStateBackend::Sqlite - }; - let import_runtime_audit = if self.import_signer_state || self.import_runtime_audit { - self.import_runtime_audit - } else { - config.persistence.runtime_audit_backend == MycRuntimeAuditBackend::Sqlite - }; - - if import_signer_state - && config.persistence.signer_state_backend != MycSignerStateBackend::Sqlite - { - return Err(MycError::InvalidOperation( - "json-to-sqlite signer-state import requires the sqlite signer-state backend" - .to_owned(), - )); - } - if import_runtime_audit - && config.persistence.runtime_audit_backend != MycRuntimeAuditBackend::Sqlite - { - return Err(MycError::InvalidOperation( - "json-to-sqlite runtime-audit import requires the sqlite runtime-audit backend" - .to_owned(), - )); - } - if !import_signer_state && !import_runtime_audit { - return Err(MycError::InvalidOperation( - "json-to-sqlite import requires at least one sqlite-backed destination".to_owned(), - )); - } - - Ok(Self { - import_signer_state, - import_runtime_audit, - }) - } -} - -pub fn import_json_to_sqlite( - config: &MycConfig, - selection: MycPersistenceImportSelection, -) -> Result<MycPersistenceImportJsonToSqliteOutput, MycError> { - config.validate()?; - let selection = selection.resolve(config)?; - let state_dir = config.paths.state_dir(); - let audit_dir = MycRuntimePaths::audit_dir_for_state_dir(state_dir); - fs::create_dir_all(state_dir).map_err(|source| MycError::CreateDir { - path: state_dir.to_path_buf(), - source, - })?; - fs::create_dir_all(&audit_dir).map_err(|source| MycError::CreateDir { - path: audit_dir.clone(), - source, - })?; - let mut output = MycPersistenceImportJsonToSqliteOutput { - signer_state: None, - runtime_audit: None, - }; - - if selection.import_signer_state { - output.signer_state = Some(import_signer_state_json_to_sqlite(config)?); - } - if selection.import_runtime_audit { - output.runtime_audit = Some(import_runtime_audit_jsonl_to_sqlite(config, &audit_dir)?); - } - - Ok(output) -} - -pub fn backup_persistence( - config: &MycConfig, - output_dir: impl AsRef<Path>, -) -> Result<MycPersistenceBackupOutput, MycError> { - config.validate()?; - - let output_dir = output_dir.as_ref().to_path_buf(); - let backup_manifest_path = output_dir.join(MYC_PERSISTENCE_BACKUP_MANIFEST_FILE_NAME); - let state_dir = config.paths.state_dir(); - let audit_dir = MycRuntimePaths::audit_dir_for_state_dir(state_dir); - let signer_state_path = MycRuntimePaths::signer_state_path_for_backend( - state_dir, - config.persistence.signer_state_backend, - ); - let runtime_audit_path = MycRuntimePaths::runtime_audit_path_for_backend( - &audit_dir, - config.persistence.runtime_audit_backend, - ); - let delivery_outbox_path = MycRuntimePaths::delivery_outbox_path_for_state_dir(state_dir); - - ensure_directory_empty_or_create(&output_dir, "backup destination")?; - require_existing_restore_file( - &signer_state_path, - format!( - "{} signer-state backend", - config.persistence.signer_state_backend.as_str() - ), - )?; - require_existing_restore_file( - &runtime_audit_path, - format!( - "{} runtime-audit backend", - config.persistence.runtime_audit_backend.as_str() - ), - )?; - require_existing_restore_file(&delivery_outbox_path, "delivery outbox".to_owned())?; - - let backup_state_dir = output_dir.join(MYC_PERSISTENCE_BACKUP_STATE_DIR_NAME); - let state_files = copy_dir_recursive_collect(state_dir, &backup_state_dir)?; - let signer_identity_reference = backup_identity_reference( - "signer", - &config.paths.signer_identity_source(), - &output_dir, - )?; - let user_identity_reference = - backup_identity_reference("user", &config.paths.user_identity_source(), &output_dir)?; - let discovery_app_identity_reference = config - .discovery - .app_identity_source() - .map(|source| backup_identity_reference("discovery-app", &source, &output_dir)) - .transpose()?; - - let manifest = MycPersistenceBackupManifest { - version: MYC_PERSISTENCE_BACKUP_MANIFEST_VERSION, - created_at_unix: now_unix_secs(), - signer_state_backend: config.persistence.signer_state_backend, - runtime_audit_backend: config.persistence.runtime_audit_backend, - state_dir: MycPersistenceBackupStateManifest { - relative_path: PathBuf::from(MYC_PERSISTENCE_BACKUP_STATE_DIR_NAME), - files: state_files.clone(), - }, - signer_identity_reference: signer_identity_reference.manifest, - user_identity_reference: user_identity_reference.manifest, - discovery_app_identity_reference: discovery_app_identity_reference - .as_ref() - .map(|output| output.manifest.clone()), - }; - write_json_file(&backup_manifest_path, &manifest)?; - - Ok(MycPersistenceBackupOutput { - backup_dir: output_dir.clone(), - manifest_path: backup_manifest_path, - state_dir: MycPersistenceBackupStateOutput { - source_path: state_dir.to_path_buf(), - destination_path: backup_state_dir, - file_count: state_files.len(), - }, - signer_identity_reference: signer_identity_reference.output, - user_identity_reference: user_identity_reference.output, - discovery_app_identity_reference: discovery_app_identity_reference - .map(|output| output.output), - }) -} - -pub fn restore_backup( - config: &MycConfig, - backup_dir: impl AsRef<Path>, -) -> Result<MycPersistenceRestoreOutput, MycError> { - config.validate()?; - - let backup_dir = backup_dir.as_ref().to_path_buf(); - let backup_manifest_path = backup_dir.join(MYC_PERSISTENCE_BACKUP_MANIFEST_FILE_NAME); - let manifest = read_json_file::<MycPersistenceBackupManifest>(&backup_manifest_path)?; - validate_backup_manifest(config, &manifest)?; - - let state_source_dir = backup_dir.join(&manifest.state_dir.relative_path); - if !state_source_dir.is_dir() { - return Err(MycError::InvalidOperation(format!( - "persistence restore requires an existing backed-up state directory at {}", - state_source_dir.display() - ))); - } - - ensure_restore_state_destination_clear(config.paths.state_dir())?; - let signer_identity_reference = restore_identity_reference( - &backup_dir, - &manifest.signer_identity_reference, - &config.paths.signer_identity_source(), - )?; - let user_identity_reference = restore_identity_reference( - &backup_dir, - &manifest.user_identity_reference, - &config.paths.user_identity_source(), - )?; - let discovery_app_identity_reference = match ( - manifest.discovery_app_identity_reference.as_ref(), - config.discovery.app_identity_source(), - ) { - (Some(manifest_reference), Some(current_source)) => Some(restore_identity_reference( - &backup_dir, - manifest_reference, - &current_source, - )?), - _ => None, - }; - - let restored_state_files = - copy_dir_recursive_collect(&state_source_dir, config.paths.state_dir())?; - - Ok(MycPersistenceRestoreOutput { - backup_dir: backup_dir.clone(), - manifest_path: backup_manifest_path, - state_dir: MycPersistenceRestoreStateOutput { - source_path: state_source_dir, - destination_path: config.paths.state_dir().to_path_buf(), - file_count: restored_state_files.len(), - }, - signer_identity_reference, - user_identity_reference, - discovery_app_identity_reference, - }) -} - -pub fn verify_restored_state( - config: &MycConfig, -) -> Result<MycPersistenceVerifyRestoreOutput, MycError> { - config.validate()?; - - let state_dir = config.paths.state_dir(); - let audit_dir = MycRuntimePaths::audit_dir_for_state_dir(state_dir); - let signer_state_path = MycRuntimePaths::signer_state_path_for_backend( - state_dir, - config.persistence.signer_state_backend, - ); - let runtime_audit_path = MycRuntimePaths::runtime_audit_path_for_backend( - &audit_dir, - config.persistence.runtime_audit_backend, - ); - let delivery_outbox_path = MycRuntimePaths::delivery_outbox_path_for_state_dir(state_dir); - - require_existing_restore_file( - &signer_state_path, - format!( - "{} signer-state backend", - config.persistence.signer_state_backend.as_str() - ), - )?; - require_existing_restore_file( - &runtime_audit_path, - format!( - "{} runtime-audit backend", - config.persistence.runtime_audit_backend.as_str() - ), - )?; - require_existing_restore_file(&delivery_outbox_path, "delivery outbox".to_owned())?; - - let signer_identity_provider = MycIdentityProvider::from_source( - "signer", - config.paths.signer_identity_source(), - Duration::from_secs(config.custody.external_command_timeout_secs), - )?; - let signer_identity = signer_identity_provider.load_active_identity()?; - let user_identity_provider = MycIdentityProvider::from_source( - "user", - config.paths.user_identity_source(), - Duration::from_secs(config.custody.external_command_timeout_secs), - )?; - let user_identity = user_identity_provider.load_active_identity()?; - let discovery_app_identity = match config.discovery.app_identity_source() { - Some(source) => Some(MycIdentityProvider::from_source( - "discovery app", - source, - Duration::from_secs(config.custody.external_command_timeout_secs), - )?), - None => None, - } - .map(|provider| provider.load_active_identity()) - .transpose()?; - - let signer_state = load_existing_signer_state(config, &signer_state_path)?; - let configured_signer_identity = signer_identity.to_public(); - if let Some(existing_signer_identity) = signer_state.signer_identity.as_ref() - && existing_signer_identity.id != configured_signer_identity.id - { - return Err(MycError::SignerIdentityMismatch { - identity_path: config.paths.signer_identity_path.clone(), - state_path: signer_state_path.clone(), - configured_identity_id: configured_signer_identity.id.to_string(), - persisted_identity_id: existing_signer_identity.id.to_string(), - }); - } - - let runtime_audit_record_count = load_existing_runtime_audit_record_count(config, &audit_dir)?; - let outbox_store = MycSqliteDeliveryOutboxStore::open(state_dir)?; - let outbox_records = outbox_store.list_all()?; - verify_restored_delivery_state( - &signer_state, - &outbox_records, - signer_identity.public_key(), - discovery_app_identity - .as_ref() - .map(|identity| identity.public_key()), - )?; - - let mut queued_job_count = 0usize; - let mut published_pending_finalize_job_count = 0usize; - let mut finalized_job_count = 0usize; - let mut failed_job_count = 0usize; - for record in &outbox_records { - match record.status { - MycDeliveryOutboxStatus::Queued => queued_job_count += 1, - MycDeliveryOutboxStatus::PublishedPendingFinalize => { - published_pending_finalize_job_count += 1 - } - MycDeliveryOutboxStatus::Finalized => finalized_job_count += 1, - MycDeliveryOutboxStatus::Failed => failed_job_count += 1, - } - } - - Ok(MycPersistenceVerifyRestoreOutput { - signer_identity_id: signer_identity.id().to_string(), - user_identity_id: user_identity.id().to_string(), - discovery_app_identity_id: discovery_app_identity - .as_ref() - .map(|identity| identity.id().to_string()), - signer_state: MycSignerStateVerifyRestoreOutput { - backend: config.persistence.signer_state_backend, - path: signer_state_path, - connection_count: signer_state.connections.len(), - request_audit_count: signer_state.audit_records.len(), - publish_workflow_count: signer_state.publish_workflows.len(), - }, - runtime_audit: MycRuntimeAuditVerifyRestoreOutput { - backend: config.persistence.runtime_audit_backend, - path: runtime_audit_path, - record_count: runtime_audit_record_count, - }, - delivery_outbox: MycDeliveryOutboxVerifyRestoreOutput { - path: delivery_outbox_path, - total_job_count: outbox_records.len(), - queued_job_count, - published_pending_finalize_job_count, - finalized_job_count, - failed_job_count, - unfinished_job_count: queued_job_count + published_pending_finalize_job_count, - }, - }) -} - -fn import_signer_state_json_to_sqlite( - config: &MycConfig, -) -> Result<MycSignerStateImportOutput, MycError> { - let source_path = MycRuntimePaths::signer_state_path_for_backend( - config.paths.state_dir(), - MycSignerStateBackend::JsonFile, - ); - let destination_path = MycRuntimePaths::signer_state_path_for_backend( - config.paths.state_dir(), - MycSignerStateBackend::Sqlite, - ); - let source_store = RadrootsNostrFileSignerStore::new(&source_path); - let source_state = source_store.load()?; - let signer_identity_provider = MycIdentityProvider::from_source( - "signer", - config.paths.signer_identity_source(), - Duration::from_secs(config.custody.external_command_timeout_secs), - )?; - let configured_signer_identity = signer_identity_provider.load_identity()?.to_public(); - if let Some(imported_signer_identity) = source_state.signer_identity.as_ref() - && imported_signer_identity.id != configured_signer_identity.id - { - return Err(MycError::SignerIdentityImportMismatch { - state_path: source_path.clone(), - configured_identity_id: configured_signer_identity.id.to_string(), - imported_identity_id: imported_signer_identity.id.to_string(), - }); - } - - let destination_store = RadrootsNostrSqliteSignerStore::open(&destination_path)?; - let existing_destination_state = destination_store.load()?; - if !signer_store_state_is_empty(&existing_destination_state) { - return Err(MycError::InvalidOperation(format!( - "sqlite signer-state destination {} is not empty; refusing import", - destination_path.display() - ))); - } - - destination_store.save(&source_state)?; - - Ok(MycSignerStateImportOutput { - source_path, - destination_path, - signer_identity_id: source_state - .signer_identity - .as_ref() - .map(|identity| identity.id.to_string()), - connection_count: source_state.connections.len(), - request_audit_count: source_state.audit_records.len(), - }) -} - -fn import_runtime_audit_jsonl_to_sqlite( - config: &MycConfig, - audit_dir: &std::path::Path, -) -> Result<MycRuntimeAuditImportOutput, MycError> { - let source_store = MycJsonlOperationAuditStore::new(audit_dir, config.audit.clone()); - let source_records = source_store.list_all()?; - let destination_store = MycSqliteOperationAuditStore::open(audit_dir, config.audit.clone())?; - let existing_destination_records = destination_store.list_all()?; - if !existing_destination_records.is_empty() { - return Err(MycError::InvalidOperation(format!( - "sqlite runtime-audit destination {} is not empty; refusing import", - destination_store.path().display() - ))); - } - for record in &source_records { - destination_store.append(record)?; - } - - Ok(MycRuntimeAuditImportOutput { - source_dir: audit_dir.to_path_buf(), - destination_path: destination_store.path().to_path_buf(), - record_count: source_records.len(), - }) -} - -#[derive(Debug, Clone)] -struct MycBackedUpIdentityReference { - manifest: MycPersistenceIdentityReferenceManifest, - output: MycPersistenceIdentityReferenceBackupOutput, -} - -fn backup_identity_reference( - role: &str, - source: &MycIdentitySourceSpec, - backup_dir: &Path, -) -> Result<MycBackedUpIdentityReference, MycError> { - let role_dir = backup_dir - .join(MYC_PERSISTENCE_BACKUP_IDENTITIES_DIR_NAME) - .join(role); - let mut manifest_files = Vec::new(); - let mut copied_files = Vec::new(); - - if should_copy_identity_source_path(source.backend) - && let Some(path) = source.path.as_ref() - { - let relative_path = PathBuf::from(MYC_PERSISTENCE_BACKUP_IDENTITIES_DIR_NAME) - .join(role) - .join("path"); - copy_file_required(path, &backup_dir.join(&relative_path))?; - manifest_files.push(MycPersistenceIdentityReferenceFileManifest { - field: MycPersistenceIdentityReferenceField::Path, - relative_path: relative_path.clone(), - }); - copied_files.push(backup_dir.join(relative_path)); - } - - if source.backend == MycIdentityBackend::EncryptedFile - && let Some(path) = source.path.as_ref() - { - let key_path = encrypted_identity_wrapping_key_path(path); - let relative_path = PathBuf::from(MYC_PERSISTENCE_BACKUP_IDENTITIES_DIR_NAME) - .join(role) - .join("encrypted-key-path"); - copy_file_required(&key_path, &backup_dir.join(&relative_path))?; - manifest_files.push(MycPersistenceIdentityReferenceFileManifest { - field: MycPersistenceIdentityReferenceField::EncryptedKeyPath, - relative_path: relative_path.clone(), - }); - copied_files.push(backup_dir.join(relative_path)); - } - - if let Some(profile_path) = source.profile_path.as_ref() { - let relative_path = PathBuf::from(MYC_PERSISTENCE_BACKUP_IDENTITIES_DIR_NAME) - .join(role) - .join("profile-path"); - copy_file_required(profile_path, &backup_dir.join(&relative_path))?; - manifest_files.push(MycPersistenceIdentityReferenceFileManifest { - field: MycPersistenceIdentityReferenceField::ProfilePath, - relative_path: relative_path.clone(), - }); - copied_files.push(backup_dir.join(relative_path)); - } - - if !manifest_files.is_empty() { - fs::create_dir_all(&role_dir).map_err(|source| MycError::CreateDir { - path: role_dir.clone(), - source, - })?; - } - - Ok(MycBackedUpIdentityReference { - manifest: MycPersistenceIdentityReferenceManifest { - role: role.to_owned(), - source: source.clone(), - files: manifest_files, - }, - output: MycPersistenceIdentityReferenceBackupOutput { - role: role.to_owned(), - backend: source.backend, - copied_file_count: copied_files.len(), - copied_files, - contains_secret_material: matches!( - source.backend, - MycIdentityBackend::EncryptedFile | MycIdentityBackend::PlaintextFile - ), - requires_out_of_backup_dependencies: matches!( - source.backend, - MycIdentityBackend::HostVault - | MycIdentityBackend::ManagedAccount - | MycIdentityBackend::ExternalCommand - ), - }, - }) -} - -fn restore_identity_reference( - backup_dir: &Path, - manifest: &MycPersistenceIdentityReferenceManifest, - current_source: &MycIdentitySourceSpec, -) -> Result<MycPersistenceIdentityReferenceRestoreOutput, MycError> { - let mut restored_files = Vec::new(); - - for file in &manifest.files { - let source_path = backup_dir.join(&file.relative_path); - let destination_path = match file.field { - MycPersistenceIdentityReferenceField::Path => current_source.path.clone(), - MycPersistenceIdentityReferenceField::EncryptedKeyPath => current_source - .path - .as_ref() - .map(encrypted_identity_wrapping_key_path), - MycPersistenceIdentityReferenceField::ProfilePath => { - current_source.profile_path.clone() - } - } - .ok_or_else(|| { - MycError::InvalidOperation(format!( - "persistence restore requires `{}` identity `{}` destination to be configured", - manifest.role, - match file.field { - MycPersistenceIdentityReferenceField::Path => "path", - MycPersistenceIdentityReferenceField::EncryptedKeyPath => { - "encrypted_key_path" - } - MycPersistenceIdentityReferenceField::ProfilePath => "profile_path", - } - )) - })?; - - ensure_restore_destination_file_clear( - &destination_path, - format!( - "{} identity {}", - manifest.role, - restore_field_label(file.field) - ), - )?; - copy_file_required(&source_path, &destination_path)?; - restored_files.push(destination_path.clone()); - } - - Ok(MycPersistenceIdentityReferenceRestoreOutput { - role: manifest.role.clone(), - backend: current_source.backend, - restored_file_count: restored_files.len(), - restored_files, - contains_secret_material: matches!( - current_source.backend, - MycIdentityBackend::EncryptedFile | MycIdentityBackend::PlaintextFile - ), - requires_out_of_backup_dependencies: matches!( - current_source.backend, - MycIdentityBackend::HostVault - | MycIdentityBackend::ManagedAccount - | MycIdentityBackend::ExternalCommand - ), - }) -} - -fn validate_backup_manifest( - config: &MycConfig, - manifest: &MycPersistenceBackupManifest, -) -> Result<(), MycError> { - if manifest.version != MYC_PERSISTENCE_BACKUP_MANIFEST_VERSION { - return Err(MycError::InvalidOperation(format!( - "persistence restore does not support backup manifest version {}; expected {}", - manifest.version, MYC_PERSISTENCE_BACKUP_MANIFEST_VERSION - ))); - } - if manifest.signer_state_backend != config.persistence.signer_state_backend { - return Err(MycError::InvalidOperation(format!( - "persistence restore requires signer-state backend `{}` but the backup was created with `{}`", - config.persistence.signer_state_backend.as_str(), - manifest.signer_state_backend.as_str() - ))); - } - if manifest.runtime_audit_backend != config.persistence.runtime_audit_backend { - return Err(MycError::InvalidOperation(format!( - "persistence restore requires runtime-audit backend `{}` but the backup was created with `{}`", - config.persistence.runtime_audit_backend.as_str(), - manifest.runtime_audit_backend.as_str() - ))); - } - validate_manifest_relative_path(&manifest.state_dir.relative_path, "state directory")?; - if manifest.state_dir.relative_path != Path::new(MYC_PERSISTENCE_BACKUP_STATE_DIR_NAME) { - return Err(MycError::InvalidOperation(format!( - "persistence restore requires the backup state directory to be stored at `{}` but found `{}`", - MYC_PERSISTENCE_BACKUP_STATE_DIR_NAME, - manifest.state_dir.relative_path.display() - ))); - } - for relative_path in &manifest.state_dir.files { - validate_manifest_relative_path(relative_path, "state file")?; - } - validate_identity_reference_manifest(&manifest.signer_identity_reference)?; - validate_identity_reference_manifest(&manifest.user_identity_reference)?; - if let Some(reference) = manifest.discovery_app_identity_reference.as_ref() { - validate_identity_reference_manifest(reference)?; - } - - validate_identity_source_compatibility( - "signer", - &config.paths.signer_identity_source(), - &manifest.signer_identity_reference.source, - )?; - validate_identity_source_compatibility( - "user", - &config.paths.user_identity_source(), - &manifest.user_identity_reference.source, - )?; - - match ( - config.discovery.app_identity_source(), - manifest.discovery_app_identity_reference.as_ref(), - ) { - (Some(current_source), Some(manifest_source)) => validate_identity_source_compatibility( - "discovery app", - &current_source, - &manifest_source.source, - )?, - (None, None) => {} - (Some(_), None) => { - return Err(MycError::InvalidOperation( - "persistence restore requires the current config discovery app identity contract to match the backup manifest".to_owned(), - )) - } - (None, Some(_)) => { - return Err(MycError::InvalidOperation( - "persistence restore requires the current config discovery app identity contract to match the backup manifest".to_owned(), - )) - } - } - - Ok(()) -} - -fn validate_identity_source_compatibility( - role: &str, - current: &MycIdentitySourceSpec, - backed_up: &MycIdentitySourceSpec, -) -> Result<(), MycError> { - if current.backend != backed_up.backend { - return Err(MycError::InvalidOperation(format!( - "persistence restore requires {role} identity backend `{}` but the backup was created with `{}`", - current.backend.as_str(), - backed_up.backend.as_str() - ))); - } - if current.keyring_account_id != backed_up.keyring_account_id { - return Err(MycError::InvalidOperation(format!( - "persistence restore requires the configured {role} keyring_account_id to match the backup manifest" - ))); - } - if current.keyring_service_name != backed_up.keyring_service_name { - return Err(MycError::InvalidOperation(format!( - "persistence restore requires the configured {role} keyring_service_name to match the backup manifest" - ))); - } - if current.profile_path.is_some() != backed_up.profile_path.is_some() { - return Err(MycError::InvalidOperation(format!( - "persistence restore requires the configured {role} profile_path contract to match the backup manifest" - ))); - } - if requires_identity_source_path_contract(current.backend) - && current.path.is_some() != backed_up.path.is_some() - { - return Err(MycError::InvalidOperation(format!( - "persistence restore requires the configured {role} path-based identity contract to match the backup manifest" - ))); - } - Ok(()) -} - -fn validate_identity_reference_manifest( - manifest: &MycPersistenceIdentityReferenceManifest, -) -> Result<(), MycError> { - for file in &manifest.files { - validate_manifest_relative_path( - &file.relative_path, - &format!("{} identity reference file", manifest.role), - )?; - } - Ok(()) -} - -fn validate_manifest_relative_path(path: &Path, label: &str) -> Result<(), MycError> { - if path.is_absolute() - || path.components().any(|component| { - matches!( - component, - Component::ParentDir | Component::RootDir | Component::Prefix(_) - ) - }) - { - return Err(MycError::InvalidOperation(format!( - "persistence restore requires a relative `{label}` path inside the backup, but found `{}`", - path.display() - ))); - } - Ok(()) -} - -fn requires_identity_source_path_contract(backend: MycIdentityBackend) -> bool { - matches!( - backend, - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ManagedAccount - | MycIdentityBackend::ExternalCommand - ) -} - -fn should_copy_identity_source_path(backend: MycIdentityBackend) -> bool { - matches!( - backend, - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ManagedAccount - ) -} - -fn ensure_directory_empty_or_create(path: &Path, label: &str) -> Result<(), MycError> { - if path.exists() { - if !path.is_dir() { - return Err(MycError::InvalidOperation(format!( - "{label} {} already exists and is not a directory", - path.display() - ))); - } - let mut entries = fs::read_dir(path).map_err(|source| MycError::PersistenceIo { - path: path.to_path_buf(), - source, - })?; - if entries - .next() - .transpose() - .map_err(|source| MycError::PersistenceIo { - path: path.to_path_buf(), - source, - })? - .is_some() - { - return Err(MycError::InvalidOperation(format!( - "{label} {} is not empty; refusing to overwrite it", - path.display() - ))); - } - return Ok(()); - } - - fs::create_dir_all(path).map_err(|source| MycError::CreateDir { - path: path.to_path_buf(), - source, - }) -} - -fn ensure_restore_state_destination_clear(path: &Path) -> Result<(), MycError> { - ensure_directory_empty_or_create(path, "restore state directory") -} - -fn ensure_restore_destination_file_clear(path: &Path, label: String) -> Result<(), MycError> { - if path.exists() { - return Err(MycError::InvalidOperation(format!( - "persistence restore requires an empty destination; {label} already exists at {}", - path.display() - ))); - } - Ok(()) -} - -fn copy_dir_recursive_collect(source: &Path, destination: &Path) -> Result<Vec<PathBuf>, MycError> { - if !source.is_dir() { - return Err(MycError::InvalidOperation(format!( - "persistence backup/restore requires a directory at {}", - source.display() - ))); - } - ensure_copy_destination_is_not_nested(source, destination)?; - - fs::create_dir_all(destination).map_err(|source_error| MycError::CreateDir { - path: destination.to_path_buf(), - source: source_error, - })?; - - let mut copied_files = Vec::new(); - copy_dir_recursive_collect_inner(source, destination, Path::new(""), &mut copied_files)?; - Ok(copied_files) -} - -fn copy_dir_recursive_collect_inner( - source_root: &Path, - destination_root: &Path, - relative_dir: &Path, - copied_files: &mut Vec<PathBuf>, -) -> Result<(), MycError> { - let current_source_dir = source_root.join(relative_dir); - let entries = fs::read_dir(&current_source_dir).map_err(|source| MycError::PersistenceIo { - path: current_source_dir.clone(), - source, - })?; - - for entry in entries { - let entry = entry.map_err(|source| MycError::PersistenceIo { - path: current_source_dir.clone(), - source, - })?; - let entry_path = entry.path(); - let relative_path = relative_dir.join(entry.file_name()); - let destination_path = destination_root.join(&relative_path); - if entry_path.is_dir() { - fs::create_dir_all(&destination_path).map_err(|source| MycError::CreateDir { - path: destination_path.clone(), - source, - })?; - copy_dir_recursive_collect_inner( - source_root, - destination_root, - &relative_path, - copied_files, - )?; - } else { - copy_file_required(&entry_path, &destination_path)?; - copied_files.push(relative_path); - } - } - - Ok(()) -} - -fn ensure_copy_destination_is_not_nested( - source: &Path, - destination: &Path, -) -> Result<(), MycError> { - let source_absolute = absolute_path_for_copy_check(source)?; - let destination_absolute = absolute_path_for_copy_check(destination)?; - if destination_absolute == source_absolute || destination_absolute.starts_with(&source_absolute) - { - return Err(MycError::InvalidOperation(format!( - "persistence backup/restore cannot copy `{}` into nested destination `{}`", - source.display(), - destination.display() - ))); - } - Ok(()) -} - -fn absolute_path_for_copy_check(path: &Path) -> Result<PathBuf, MycError> { - if path.is_absolute() { - Ok(path.to_path_buf()) - } else { - std::env::current_dir() - .map(|cwd| cwd.join(path)) - .map_err(|source| MycError::PersistenceIo { - path: path.to_path_buf(), - source, - }) - } -} - -fn copy_file_required(source: &Path, destination: &Path) -> Result<(), MycError> { - if !source.is_file() { - return Err(MycError::InvalidOperation(format!( - "persistence backup/restore requires an existing file at {}", - source.display() - ))); - } - if let Some(parent) = destination.parent() { - fs::create_dir_all(parent).map_err(|source_error| MycError::CreateDir { - path: parent.to_path_buf(), - source: source_error, - })?; - } - fs::copy(source, destination).map_err(|source_error| MycError::PersistenceIo { - path: source.to_path_buf(), - source: source_error, - })?; - Ok(()) -} - -fn write_json_file(path: &Path, value: &impl Serialize) -> Result<(), MycError> { - let rendered = - serde_json::to_string_pretty(value).map_err(|source| MycError::PersistenceSerialize { - path: path.to_path_buf(), - source, - })?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|source| MycError::CreateDir { - path: parent.to_path_buf(), - source, - })?; - } - fs::write(path, rendered).map_err(|source| MycError::PersistenceIo { - path: path.to_path_buf(), - source, - })?; - Ok(()) -} - -fn read_json_file<T>(path: &Path) -> Result<T, MycError> -where - T: for<'de> Deserialize<'de>, -{ - let contents = fs::read_to_string(path).map_err(|source| MycError::PersistenceIo { - path: path.to_path_buf(), - source, - })?; - serde_json::from_str(&contents).map_err(|source| MycError::PersistenceManifestParse { - path: path.to_path_buf(), - source, - }) -} - -fn restore_field_label(field: MycPersistenceIdentityReferenceField) -> &'static str { - match field { - MycPersistenceIdentityReferenceField::Path => "path", - MycPersistenceIdentityReferenceField::EncryptedKeyPath => "encrypted_key_path", - MycPersistenceIdentityReferenceField::ProfilePath => "profile_path", - } -} - -fn now_unix_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or(0) -} - -fn signer_store_state_is_empty( - state: &crate::signer::prelude::RadrootsNostrSignerStoreState, -) -> bool { - state.signer_identity.is_none() - && state.connections.is_empty() - && state.audit_records.is_empty() - && state.publish_workflows.is_empty() -} - -fn require_existing_restore_file(path: &std::path::Path, label: String) -> Result<(), MycError> { - if path.is_file() { - return Ok(()); - } - Err(MycError::InvalidOperation(format!( - "persistence verify-restore requires an existing {label} file at {}", - path.display() - ))) -} - -fn load_existing_signer_state( - config: &MycConfig, - signer_state_path: &std::path::Path, -) -> Result<RadrootsNostrSignerStoreState, MycError> { - match config.persistence.signer_state_backend { - MycSignerStateBackend::JsonFile => RadrootsNostrFileSignerStore::new(signer_state_path) - .load() - .map_err(MycError::from), - MycSignerStateBackend::Sqlite => RadrootsNostrSqliteSignerStore::open(signer_state_path)? - .load() - .map_err(MycError::from), - } -} - -fn load_existing_runtime_audit_record_count( - config: &MycConfig, - audit_dir: &std::path::Path, -) -> Result<usize, MycError> { - match config.persistence.runtime_audit_backend { - MycRuntimeAuditBackend::JsonlFile => Ok(MycJsonlOperationAuditStore::new( - audit_dir, - config.audit.clone(), - ) - .list_all()? - .len()), - MycRuntimeAuditBackend::Sqlite => Ok(MycSqliteOperationAuditStore::open( - audit_dir, - config.audit.clone(), - )? - .list_all()? - .len()), - } -} - -fn verify_restored_delivery_state( - signer_state: &RadrootsNostrSignerStoreState, - outbox_records: &[MycDeliveryOutboxRecord], - signer_public_key: PublicKey, - discovery_app_public_key: Option<PublicKey>, -) -> Result<(), MycError> { - let connections_by_id = signer_state - .connections - .iter() - .map(|connection| (connection.connection_id.as_str().to_owned(), connection)) - .collect::<BTreeMap<_, _>>(); - let workflows_by_id = signer_state - .publish_workflows - .iter() - .map(|workflow| (workflow.workflow_id.as_str().to_owned(), workflow)) - .collect::<BTreeMap<_, _>>(); - let mut referenced_unfinished_workflow_ids = BTreeSet::new(); - - for record in outbox_records { - verify_discovery_restore_author(record, signer_public_key, discovery_app_public_key)?; - - let recoverable_logout_failure = record.status == MycDeliveryOutboxStatus::Failed - && record.kind == MycDeliveryOutboxKind::LogoutAcknowledgementPublish; - if !matches!( - record.status, - MycDeliveryOutboxStatus::Queued | MycDeliveryOutboxStatus::PublishedPendingFinalize - ) && !recoverable_logout_failure - { - continue; - } - - let workflow = match record.signer_publish_workflow_id.as_ref() { - Some(workflow_id) => { - referenced_unfinished_workflow_ids.insert(workflow_id.as_str().to_owned()); - workflows_by_id.get(workflow_id.as_str()).copied() - } - None => None, - }; - - verify_restore_outbox_record(record, workflow, &connections_by_id)?; - } - - let orphaned_workflows = signer_state - .publish_workflows - .iter() - .filter(|workflow| { - !referenced_unfinished_workflow_ids.contains(workflow.workflow_id.as_str()) - }) - .map(|workflow| { - format!( - "{}:{}:{:?}", - workflow.workflow_id, workflow.connection_id, workflow.kind - ) - }) - .collect::<Vec<_>>(); - if !orphaned_workflows.is_empty() { - return Err(MycError::InvalidOperation(format!( - "persistence verify-restore found orphaned signer publish workflows with no unfinished delivery outbox job: {}", - orphaned_workflows.join(", ") - ))); - } - - Ok(()) -} - -fn verify_discovery_restore_author( - record: &MycDeliveryOutboxRecord, - signer_public_key: PublicKey, - discovery_app_public_key: Option<PublicKey>, -) -> Result<(), MycError> { - if record.kind != MycDeliveryOutboxKind::DiscoveryHandlerPublish { - return Ok(()); - } - if record.event.pubkey == signer_public_key - || discovery_app_public_key == Some(record.event.pubkey) - { - return Ok(()); - } - - Err(MycError::InvalidOperation(format!( - "persistence verify-restore found discovery delivery outbox job `{}` authored by `{}` but the configured signer/discovery identities do not match", - record.job_id, record.event.pubkey - ))) -} - -fn verify_restore_outbox_record<'a>( - record: &MycDeliveryOutboxRecord, - workflow: Option<&'a RadrootsNostrSignerPublishWorkflowRecord>, - connections_by_id: &BTreeMap<String, &'a RadrootsNostrSignerConnectionRecord>, -) -> Result<(), MycError> { - match record.kind { - MycDeliveryOutboxKind::DiscoveryHandlerPublish => { - if record.signer_publish_workflow_id.is_some() { - return Err(MycError::InvalidOperation(format!( - "persistence verify-restore found discovery delivery outbox job `{}` that incorrectly references a signer publish workflow", - record.job_id - ))); - } - } - MycDeliveryOutboxKind::LogoutAcknowledgementPublish => { - if record.signer_publish_workflow_id.is_some() { - return Err(MycError::InvalidOperation(format!( - "persistence verify-restore found logout acknowledgement delivery outbox job `{}` that incorrectly references a signer publish workflow", - record.job_id - ))); - } - let connection_id = record.connection_id.as_ref().ok_or_else(|| { - MycError::InvalidOperation(format!( - "persistence verify-restore found logout acknowledgement delivery outbox job `{}` without a connection id", - record.job_id - )) - })?; - if !connections_by_id.contains_key(connection_id.as_str()) { - return Err(MycError::InvalidOperation(format!( - "persistence verify-restore found logout acknowledgement delivery outbox job `{}` referencing missing connection `{connection_id}`", - record.job_id - ))); - } - } - MycDeliveryOutboxKind::ConnectAcceptPublish | MycDeliveryOutboxKind::AuthReplayPublish => { - if record.signer_publish_workflow_id.is_none() { - return Err(MycError::InvalidOperation(format!( - "persistence verify-restore found control delivery outbox job `{}` without a signer publish workflow", - record.job_id - ))); - } - } - MycDeliveryOutboxKind::ListenerResponsePublish => {} - } - - match workflow { - Some(workflow) => { - let expected_kind = match record.kind { - MycDeliveryOutboxKind::ListenerResponsePublish - | MycDeliveryOutboxKind::ConnectAcceptPublish => { - RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization - } - MycDeliveryOutboxKind::AuthReplayPublish => { - RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization - } - MycDeliveryOutboxKind::DiscoveryHandlerPublish - | MycDeliveryOutboxKind::LogoutAcknowledgementPublish => unreachable!(), - }; - if workflow.kind != expected_kind { - return Err(MycError::InvalidOperation(format!( - "persistence verify-restore found delivery outbox job `{}` expecting signer workflow kind `{:?}` but found `{:?}`", - record.job_id, expected_kind, workflow.kind - ))); - } - - let connection_id = record.connection_id.as_ref().ok_or_else(|| { - MycError::InvalidOperation(format!( - "persistence verify-restore found delivery outbox job `{}` missing a connection id required for signer workflow verification", - record.job_id - )) - })?; - if workflow.connection_id.as_str() != connection_id.as_str() { - return Err(MycError::InvalidOperation(format!( - "persistence verify-restore found delivery outbox job `{}` bound to connection `{connection_id}` but signer workflow `{}` is bound to `{}`", - record.job_id, workflow.workflow_id, workflow.connection_id - ))); - } - if record.status == MycDeliveryOutboxStatus::PublishedPendingFinalize - && workflow.state - != RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize - { - return Err(MycError::InvalidOperation(format!( - "persistence verify-restore found delivery outbox job `{}` waiting for finalize but signer workflow `{}` is in `{:?}`", - record.job_id, workflow.workflow_id, workflow.state - ))); - } - } - None => { - if record.signer_publish_workflow_id.is_some() { - if record.status == MycDeliveryOutboxStatus::PublishedPendingFinalize { - verify_already_finalized_without_workflow(record, connections_by_id)?; - } else { - return Err(MycError::InvalidOperation(format!( - "persistence verify-restore found delivery outbox job `{}` referencing a missing signer publish workflow before finalize", - record.job_id - ))); - } - } - } - } - - Ok(()) -} - -fn verify_already_finalized_without_workflow( - record: &MycDeliveryOutboxRecord, - connections_by_id: &BTreeMap<String, &RadrootsNostrSignerConnectionRecord>, -) -> Result<(), MycError> { - let workflow_id = record.signer_publish_workflow_id.as_ref().ok_or_else(|| { - MycError::InvalidOperation(format!( - "persistence verify-restore found delivery outbox job `{}` missing a signer workflow id for finalization verification", - record.job_id - )) - })?; - let connection_id = record.connection_id.as_ref().ok_or_else(|| { - MycError::InvalidOperation(format!( - "persistence verify-restore found delivery outbox job `{}` missing a connection id for finalization verification", - record.job_id - )) - })?; - let connection = connections_by_id - .get(connection_id.as_str()) - .copied() - .ok_or_else(|| { - MycError::InvalidOperation(format!( - "persistence verify-restore found delivery outbox job `{}` referencing missing connection `{connection_id}`", - record.job_id - )) - })?; - - match record.kind { - MycDeliveryOutboxKind::ListenerResponsePublish - | MycDeliveryOutboxKind::ConnectAcceptPublish => { - if !connection.connect_secret_is_consumed() { - return Err(MycError::InvalidOperation(format!( - "persistence verify-restore found delivery outbox job `{}` referencing connect workflow `{workflow_id}` but the connection secret is still reusable", - record.job_id - ))); - } - } - MycDeliveryOutboxKind::AuthReplayPublish => { - if connection.auth_state != RadrootsNostrSignerAuthState::Authorized - || connection.pending_request.is_some() - { - return Err(MycError::InvalidOperation(format!( - "persistence verify-restore found delivery outbox job `{}` referencing auth replay workflow `{workflow_id}` but the connection auth state is not finalized", - record.job_id - ))); - } - } - MycDeliveryOutboxKind::DiscoveryHandlerPublish => { - return Err(MycError::InvalidOperation(format!( - "persistence verify-restore found discovery delivery outbox job `{}` unexpectedly referencing signer workflow `{workflow_id}`", - record.job_id - ))); - } - MycDeliveryOutboxKind::LogoutAcknowledgementPublish => { - return Err(MycError::InvalidOperation(format!( - "persistence verify-restore found logout acknowledgement delivery outbox job `{}` unexpectedly referencing signer workflow `{workflow_id}`", - record.job_id - ))); - } - } - - Ok(()) -} -#[cfg(test)] -mod tests { - use std::path::{Path, PathBuf}; - - use crate::host_identity::RadrootsIdentity; - use crate::nostr_contract::{ - RadrootsNostrEvent, RadrootsNostrGenericEventBuilder, RadrootsNostrKind, - }; - use crate::signer::prelude::{ - RADROOTS_NOSTR_SIGNER_STORE_VERSION, RadrootsNostrFileSignerStore, - RadrootsNostrSignerConnectionDraft, RadrootsNostrSignerConnectionId, - RadrootsNostrSignerStore, RadrootsNostrSignerStoreState, RadrootsNostrSignerWorkflowId, - RadrootsNostrSqliteSignerStore, - }; - use nostr::PublicKey; - - use super::{ - MycPersistenceImportSelection, import_json_to_sqlite, signer_store_state_is_empty, - verify_restored_delivery_state, - }; - use crate::app::MycRuntime; - use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; - use crate::audit_sqlite::MycSqliteOperationAuditStore; - use crate::config::{MycConfig, MycRuntimeAuditBackend, MycSignerStateBackend}; - use crate::error::MycError; - use crate::outbox::{MycDeliveryOutboxKind, MycDeliveryOutboxRecord}; - - const SIGNER_SECRET_KEY: &str = - "1111111111111111111111111111111111111111111111111111111111111111"; - const USER_SECRET_KEY: &str = - "2222222222222222222222222222222222222222222222222222222222222222"; - const OTHER_SECRET_KEY: &str = - "3333333333333333333333333333333333333333333333333333333333333333"; - - fn write_identity(path: &Path, secret_key: &str) { - let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); - crate::identity_files::store_encrypted_identity(path, &identity).expect("save identity"); - } - - fn identity(secret_key: &str) -> RadrootsIdentity { - RadrootsIdentity::from_secret_key_str(secret_key).expect("identity") - } - - fn signer_identity() -> RadrootsIdentity { - identity(SIGNER_SECRET_KEY) - } - - fn user_identity() -> RadrootsIdentity { - identity(USER_SECRET_KEY) - } - - fn signed_event(secret_key: &str) -> RadrootsNostrEvent { - RadrootsNostrGenericEventBuilder::new(RadrootsNostrKind::Custom(24133), "hello") - .sign_with_keys(identity(secret_key).keys()) - .expect("sign event") - } - - fn outbox_record(kind: MycDeliveryOutboxKind, secret_key: &str) -> MycDeliveryOutboxRecord { - MycDeliveryOutboxRecord::new( - kind, - signed_event(secret_key), - vec!["wss://relay.example.com".parse().expect("relay")], - ) - .expect("record") - } - - fn client_public_key(value: &str) -> PublicKey { - PublicKey::from_hex(value).expect("pubkey") - } - - fn load_json_signer_state(temp: &Path) -> RadrootsNostrSignerStoreState { - let config = crate::config::test_config(temp); - RadrootsNostrFileSignerStore::new(config.paths.state_dir().join("signer-state.json")) - .load() - .expect("load signer state") - } - - fn empty_signer_state() -> RadrootsNostrSignerStoreState { - RadrootsNostrSignerStoreState { - version: RADROOTS_NOSTR_SIGNER_STORE_VERSION, - signer_identity: None, - connections: Vec::new(), - audit_records: Vec::new(), - publish_workflows: Vec::new(), - } - } - - fn base_config(temp: &Path) -> MycConfig { - let mut config = crate::config::test_config(temp); - config.paths.signer_identity_path = temp.join("signer.json"); - config.paths.user_identity_path = temp.join("user.json"); - write_identity(&config.paths.signer_identity_path, SIGNER_SECRET_KEY); - write_identity(&config.paths.user_identity_path, USER_SECRET_KEY); - config - } - - fn bootstrap_json_runtime(temp: &Path) -> MycRuntime { - let config = base_config(temp); - MycRuntime::bootstrap(config).expect("runtime") - } - - #[test] - fn signer_store_state_is_not_empty_when_only_publish_workflows_are_present() { - let workflow = crate::signer::prelude::RadrootsNostrSignerPublishWorkflowRecord::new_connect_secret_finalization( - RadrootsNostrSignerConnectionId::parse("workflow-only-connection") - .expect("workflow connection id"), - 17, - ); - let state = RadrootsNostrSignerStoreState { - version: RADROOTS_NOSTR_SIGNER_STORE_VERSION, - signer_identity: None, - connections: Vec::new(), - audit_records: Vec::new(), - publish_workflows: vec![workflow], - }; - - assert!( - !signer_store_state_is_empty(&state), - "publish workflows must make the signer-state destination non-empty" - ); - } - - #[test] - fn verify_restore_rejects_orphaned_signer_publish_workflows() { - let temp = tempfile::tempdir().expect("tempdir"); - let runtime = bootstrap_json_runtime(temp.path()); - let manager = runtime.signer_manager().expect("manager"); - let connection = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new( - client_public_key( - "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ), - runtime.user_public_identity(), - ) - .with_connect_secret("orphan-secret"), - ) - .expect("register connection"); - manager - .begin_connect_secret_publish_finalization(&connection.connection_id) - .expect("begin workflow"); - - let signer_state = load_json_signer_state(temp.path()); - let err = verify_restored_delivery_state( - &signer_state, - &[], - signer_identity().public_key(), - None, - ) - .expect_err("orphaned workflow should fail restore verification"); - - assert!( - err.to_string() - .contains("orphaned signer publish workflows") - ); - } - - #[test] - fn verify_restore_rejects_discovery_author_mismatch() { - let signer_state = empty_signer_state(); - let record = outbox_record( - MycDeliveryOutboxKind::DiscoveryHandlerPublish, - OTHER_SECRET_KEY, - ); - - let err = verify_restored_delivery_state( - &signer_state, - &[record], - signer_identity().public_key(), - Some(user_identity().public_key()), - ) - .expect_err("unexpected discovery author should fail restore verification"); - - assert!( - err.to_string() - .contains("configured signer/discovery identities do not match") - ); - } - - #[test] - fn verify_restore_rejects_missing_workflow_before_finalize() { - let signer_state = empty_signer_state(); - let workflow_id = - RadrootsNostrSignerWorkflowId::parse("missing-workflow").expect("workflow id"); - let record = outbox_record( - MycDeliveryOutboxKind::ListenerResponsePublish, - SIGNER_SECRET_KEY, - ) - .with_signer_publish_workflow_id(&workflow_id); - - let err = verify_restored_delivery_state( - &signer_state, - &[record], - signer_identity().public_key(), - None, - ) - .expect_err("missing unfinished workflow should fail restore verification"); - - assert!( - err.to_string() - .contains("referencing a missing signer publish workflow before finalize") - ); - } - - #[test] - fn verify_restore_accepts_published_pending_finalize_job_after_connect_finalization() { - let temp = tempfile::tempdir().expect("tempdir"); - let runtime = bootstrap_json_runtime(temp.path()); - let manager = runtime.signer_manager().expect("manager"); - let connection = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new( - client_public_key( - "c6047f9441ed7d6d3045406e95c07cd85a65f77e53bde42a6d0f46b4f0f92b4f", - ), - runtime.user_public_identity(), - ) - .with_connect_secret("accepted-secret"), - ) - .expect("register connection"); - let workflow = manager - .begin_connect_secret_publish_finalization(&connection.connection_id) - .expect("begin workflow"); - manager - .mark_publish_workflow_published(&workflow.workflow_id) - .expect("mark published"); - manager - .finalize_publish_workflow(&workflow.workflow_id) - .expect("finalize workflow"); - - let signer_state = load_json_signer_state(temp.path()); - let mut record = outbox_record( - MycDeliveryOutboxKind::ListenerResponsePublish, - SIGNER_SECRET_KEY, - ) - .with_connection_id(&connection.connection_id) - .with_signer_publish_workflow_id(&workflow.workflow_id); - record - .mark_published_pending_finalize(1, record.created_at_unix + 1) - .expect("mark published"); - - verify_restored_delivery_state( - &signer_state, - &[record], - signer_identity().public_key(), - None, - ) - .expect("already-finalized connect workflow should be accepted"); - } - - #[test] - fn verify_restore_rejects_wrong_workflow_kind() { - let temp = tempfile::tempdir().expect("tempdir"); - let runtime = bootstrap_json_runtime(temp.path()); - let manager = runtime.signer_manager().expect("manager"); - let connection = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new( - client_public_key( - "f9308a019258c3106f85b9d5b3e8c8f923dc4bde7b5b6d8f8f9ad7881e5341e5", - ), - runtime.user_public_identity(), - ) - .with_connect_secret("kind-secret"), - ) - .expect("register connection"); - let workflow = manager - .begin_connect_secret_publish_finalization(&connection.connection_id) - .expect("begin workflow"); - - let signer_state = load_json_signer_state(temp.path()); - let record = outbox_record(MycDeliveryOutboxKind::AuthReplayPublish, SIGNER_SECRET_KEY) - .with_connection_id(&connection.connection_id) - .with_signer_publish_workflow_id(&workflow.workflow_id); - - let err = verify_restored_delivery_state( - &signer_state, - &[record], - signer_identity().public_key(), - None, - ) - .expect_err("workflow kind mismatch should fail restore verification"); - - assert!(err.to_string().contains("expecting signer workflow kind")); - } - - #[test] - fn verify_restore_rejects_wrong_connection_binding() { - let temp = tempfile::tempdir().expect("tempdir"); - let runtime = bootstrap_json_runtime(temp.path()); - let manager = runtime.signer_manager().expect("manager"); - let first = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new( - client_public_key( - "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ), - runtime.user_public_identity(), - ) - .with_connect_secret("first-secret"), - ) - .expect("register first"); - let second = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new( - client_public_key( - "c6047f9441ed7d6d3045406e95c07cd85a65f77e53bde42a6d0f46b4f0f92b4f", - ), - runtime.user_public_identity(), - ) - .with_connect_secret("second-secret"), - ) - .expect("register second"); - let workflow = manager - .begin_connect_secret_publish_finalization(&first.connection_id) - .expect("begin workflow"); - - let signer_state = load_json_signer_state(temp.path()); - let record = outbox_record( - MycDeliveryOutboxKind::ListenerResponsePublish, - SIGNER_SECRET_KEY, - ) - .with_connection_id(&second.connection_id) - .with_signer_publish_workflow_id(&workflow.workflow_id); - - let err = verify_restored_delivery_state( - &signer_state, - &[record], - signer_identity().public_key(), - None, - ) - .expect_err("workflow connection mismatch should fail restore verification"); - - assert!(err.to_string().contains("is bound to")); - } - - #[test] - fn verify_restore_rejects_missing_connection_id_for_workflow_job() { - let temp = tempfile::tempdir().expect("tempdir"); - let runtime = bootstrap_json_runtime(temp.path()); - let manager = runtime.signer_manager().expect("manager"); - let connection = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new( - client_public_key( - "f9308a019258c3106f85b9d5b3e8c8f923dc4bde7b5b6d8f8f9ad7881e5341e5", - ), - runtime.user_public_identity(), - ) - .with_connect_secret("missing-connection-id-secret"), - ) - .expect("register connection"); - let workflow = manager - .begin_connect_secret_publish_finalization(&connection.connection_id) - .expect("begin workflow"); - - let signer_state = load_json_signer_state(temp.path()); - let record = outbox_record( - MycDeliveryOutboxKind::ListenerResponsePublish, - SIGNER_SECRET_KEY, - ) - .with_signer_publish_workflow_id(&workflow.workflow_id); - - let err = verify_restored_delivery_state( - &signer_state, - &[record], - signer_identity().public_key(), - None, - ) - .expect_err("missing connection id should fail restore verification"); - - assert!( - err.to_string() - .contains("missing a connection id required for signer workflow verification") - ); - } - - #[test] - fn import_json_to_sqlite_moves_signer_state_and_runtime_audit() { - let temp = tempfile::tempdir().expect("tempdir"); - let runtime = bootstrap_json_runtime(temp.path()); - let manager = runtime.signer_manager().expect("manager"); - let connection = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - PublicKey::from_hex( - "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .expect("pubkey"), - runtime.user_public_identity(), - )) - .expect("register connection"); - runtime.record_operation_audit(&MycOperationAuditRecord::new( - MycOperationAuditKind::ListenerResponsePublish, - MycOperationAuditOutcome::Succeeded, - Some(&connection.connection_id), - Some("request-1"), - 1, - 1, - "publish succeeded", - )); - - let mut sqlite_config = base_config(temp.path()); - sqlite_config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; - sqlite_config.persistence.runtime_audit_backend = MycRuntimeAuditBackend::Sqlite; - - let output = import_json_to_sqlite( - &sqlite_config, - MycPersistenceImportSelection::new(false, false), - ) - .expect("import"); - - assert_eq!( - output - .signer_state - .as_ref() - .expect("signer-state output") - .connection_count, - 1 - ); - assert_eq!( - output - .runtime_audit - .as_ref() - .expect("runtime-audit output") - .record_count, - 1 - ); - - let imported_runtime = MycRuntime::bootstrap(sqlite_config).expect("sqlite runtime"); - assert_eq!( - imported_runtime - .signer_manager() - .expect("manager") - .list_connections() - .expect("connections") - .len(), - 1 - ); - assert_eq!( - imported_runtime - .operation_audit_store() - .list_all() - .expect("audit records") - .len(), - 1 - ); - } - - #[test] - fn import_signer_state_rejects_non_empty_sqlite_destination() { - let temp = tempfile::tempdir().expect("tempdir"); - let runtime = bootstrap_json_runtime(temp.path()); - let manager = runtime.signer_manager().expect("manager"); - manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - PublicKey::from_hex( - "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .expect("pubkey"), - runtime.user_public_identity(), - )) - .expect("register connection"); - - let mut sqlite_config = base_config(temp.path()); - sqlite_config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; - - let sqlite_store = RadrootsNostrSqliteSignerStore::open( - sqlite_config.paths.state_dir().join("signer-state.sqlite"), - ) - .expect("sqlite store"); - let existing_state = RadrootsNostrFileSignerStore::new( - sqlite_config.paths.state_dir().join("signer-state.json"), - ) - .load() - .expect("load source state"); - sqlite_store - .save(&existing_state) - .expect("save sqlite state"); - - let err = import_json_to_sqlite( - &sqlite_config, - MycPersistenceImportSelection::new(true, false), - ) - .expect_err("non-empty sqlite signer destination should fail"); - - assert!(err.to_string().contains("sqlite signer-state destination")); - } - - #[test] - fn import_runtime_audit_rejects_non_empty_sqlite_destination() { - let temp = tempfile::tempdir().expect("tempdir"); - let runtime = bootstrap_json_runtime(temp.path()); - runtime.record_operation_audit(&MycOperationAuditRecord::new( - MycOperationAuditKind::ListenerResponsePublish, - MycOperationAuditOutcome::Succeeded, - None, - Some("request-1"), - 1, - 1, - "publish succeeded", - )); - - let mut sqlite_config = base_config(temp.path()); - sqlite_config.persistence.runtime_audit_backend = MycRuntimeAuditBackend::Sqlite; - - let sqlite_audit_store = MycSqliteOperationAuditStore::open( - sqlite_config.paths.state_dir().join("audit"), - sqlite_config.audit.clone(), - ) - .expect("sqlite audit store"); - sqlite_audit_store - .append(&MycOperationAuditRecord::new( - MycOperationAuditKind::AuthReplayRestore, - MycOperationAuditOutcome::Restored, - None, - Some("request-2"), - 1, - 0, - "restored pending auth challenge", - )) - .expect("append"); - - let err = import_json_to_sqlite( - &sqlite_config, - MycPersistenceImportSelection::new(false, true), - ) - .expect_err("non-empty sqlite audit destination should fail"); - - assert!(err.to_string().contains("sqlite runtime-audit destination")); - } - - #[test] - fn import_signer_state_rejects_mismatched_configured_signer_identity() { - let temp = tempfile::tempdir().expect("tempdir"); - let runtime = bootstrap_json_runtime(temp.path()); - let manager = runtime.signer_manager().expect("manager"); - manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - PublicKey::from_hex( - "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .expect("pubkey"), - runtime.user_public_identity(), - )) - .expect("register connection"); - - let mut sqlite_config = base_config(temp.path()); - let other_signer_path = PathBuf::from(temp.path()).join("other-signer.json"); - write_identity( - &other_signer_path, - "3333333333333333333333333333333333333333333333333333333333333333", - ); - sqlite_config.paths.signer_identity_path = other_signer_path; - sqlite_config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; - - let err = import_json_to_sqlite( - &sqlite_config, - MycPersistenceImportSelection::new(true, false), - ) - .expect_err("mismatched signer identity should fail"); - - assert!(matches!(err, MycError::SignerIdentityImportMismatch { .. })); - } -} diff --git a/src/signer/migrations.rs b/src/signer/migrations.rs @@ -1,35 +0,0 @@ -use crate::sql::SqlExecutor; -use crate::sql::error::SqlError; -use crate::sql::migrations::{Migration, migrations_run_all_down, migrations_run_all_up}; - -pub static MIGRATIONS: &[Migration] = &[ - Migration { - name: "0000_init", - up_sql: include_str!("../../migrations/signer/0000_init.up.sql"), - down_sql: include_str!("../../migrations/signer/0000_init.down.sql"), - }, - Migration { - name: "0001_publish_workflows", - up_sql: include_str!("../../migrations/signer/0001_publish_workflows.up.sql"), - down_sql: include_str!("../../migrations/signer/0001_publish_workflows.down.sql"), - }, - Migration { - name: "0002_client_metadata", - up_sql: include_str!("../../migrations/signer/0002_client_metadata.up.sql"), - down_sql: include_str!("../../migrations/signer/0002_client_metadata.down.sql"), - }, -]; - -pub fn run_all_up<E>(executor: &E) -> Result<(), SqlError> -where - E: SqlExecutor, -{ - migrations_run_all_up(executor, MIGRATIONS) -} - -pub fn run_all_down<E>(executor: &E) -> Result<(), SqlError> -where - E: SqlExecutor, -{ - migrations_run_all_down(executor, MIGRATIONS) -} diff --git a/src/signer/sqlite.rs b/src/signer/sqlite.rs @@ -1,291 +0,0 @@ -use crate::signer::error::RadrootsNostrSignerError; -use crate::signer::migrations; -use crate::sql::{SqlExecutor, SqlxSqliteExecutor}; -use serde::Deserialize; -use std::path::Path; - -#[derive(Deserialize)] -struct SqliteJournalModeRow { - journal_mode: String, -} - -pub struct RadrootsNostrSignerSqliteDb { - executor: SqlxSqliteExecutor, - file_backed: bool, -} - -impl RadrootsNostrSignerSqliteDb { - pub fn open(path: impl AsRef<Path>) -> Result<Self, RadrootsNostrSignerError> { - let path = path.as_ref(); - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() - { - std::fs::create_dir_all(parent) - .map_err(|error| RadrootsNostrSignerError::Store(error.to_string()))?; - } - let executor = SqlxSqliteExecutor::open(path)?; - let db = Self { - executor, - file_backed: true, - }; - db.configure()?; - db.migrate_up()?; - Ok(db) - } - - pub fn open_memory() -> Result<Self, RadrootsNostrSignerError> { - let executor = SqlxSqliteExecutor::open_memory()?; - let db = Self { - executor, - file_backed: false, - }; - db.configure()?; - db.migrate_up()?; - Ok(db) - } - - pub fn executor(&self) -> &SqlxSqliteExecutor { - &self.executor - } - - pub fn migrate_up(&self) -> Result<(), RadrootsNostrSignerError> { - migrations::run_all_up(&self.executor)?; - Ok(()) - } - - pub fn migrate_down(&self) -> Result<(), RadrootsNostrSignerError> { - migrations::run_all_down(&self.executor)?; - Ok(()) - } - - fn configure(&self) -> Result<(), RadrootsNostrSignerError> { - let pragma_batch = if self.file_backed { - "PRAGMA foreign_keys = ON; - PRAGMA synchronous = FULL; - PRAGMA wal_autocheckpoint = 1000; - PRAGMA busy_timeout = 5000; - PRAGMA temp_store = MEMORY;" - } else { - "PRAGMA foreign_keys = ON; - PRAGMA synchronous = NORMAL; - PRAGMA busy_timeout = 5000; - PRAGMA temp_store = MEMORY;" - }; - let _ = self.executor.exec(pragma_batch, "[]")?; - let (journal_mode_sql, expected_journal_mode) = if self.file_backed { - ("PRAGMA main.journal_mode = WAL", "wal") - } else { - ("PRAGMA main.journal_mode = MEMORY", "memory") - }; - let result = self.executor.query_raw(journal_mode_sql, "[]")?; - validate_journal_mode_result(&result, expected_journal_mode) - } -} - -fn validate_journal_mode_result( - result: &str, - expected: &'static str, -) -> Result<(), RadrootsNostrSignerError> { - let rows: Vec<SqliteJournalModeRow> = serde_json::from_str(result)?; - let [row] = rows.as_slice() else { - return Err( - RadrootsNostrSignerError::SqliteJournalModeResultCardinality { - actual_rows: rows.len(), - }, - ); - }; - if row.journal_mode != expected { - return Err(RadrootsNostrSignerError::SqliteJournalModeMismatch { - expected, - actual: row.journal_mode.clone(), - }); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::{RadrootsNostrSignerSqliteDb, validate_journal_mode_result}; - use crate::signer::error::RadrootsNostrSignerError; - use crate::sql::SqlExecutor; - use serde_json::Value; - - fn query_values( - db: &RadrootsNostrSignerSqliteDb, - sql: &str, - ) -> Vec<serde_json::Map<String, Value>> { - let raw = db.executor().query_raw(sql, "[]").expect("query"); - serde_json::from_str::<Vec<serde_json::Map<String, Value>>>(&raw).expect("rows") - } - - fn query_single_text(db: &RadrootsNostrSignerSqliteDb, sql: &str, field: &str) -> String { - query_values(db, sql) - .into_iter() - .next() - .and_then(|row| row.get(field).cloned()) - .and_then(|value| value.as_str().map(ToOwned::to_owned)) - .expect("single text row") - } - - fn query_single_i64(db: &RadrootsNostrSignerSqliteDb, sql: &str, field: &str) -> i64 { - query_values(db, sql) - .into_iter() - .next() - .and_then(|row| row.get(field).cloned()) - .and_then(|value| value.as_i64()) - .expect("single integer row") - } - - #[test] - fn open_memory_bootstraps_schema_and_migrations_idempotently() { - let db = RadrootsNostrSignerSqliteDb::open_memory().expect("open memory db"); - db.migrate_up().expect("rerun migrations"); - assert_eq!( - query_single_text(&db, "PRAGMA main.journal_mode", "journal_mode"), - "memory" - ); - - let tables = query_values( - &db, - "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name", - ); - let table_names = tables - .into_iter() - .filter_map(|row| { - row.get("name") - .and_then(Value::as_str) - .map(ToOwned::to_owned) - }) - .collect::<Vec<_>>(); - assert!(table_names.iter().any(|name| name == "__migrations")); - assert!( - table_names - .iter() - .any(|name| name == "signer_store_metadata") - ); - assert!(table_names.iter().any(|name| name == "signer_connection")); - assert!( - table_names - .iter() - .any(|name| name == "signer_connection_permission_grant") - ); - assert!( - table_names - .iter() - .any(|name| name == "signer_connection_relay") - ); - assert!( - table_names - .iter() - .any(|name| name == "signer_connection_auth_challenge") - ); - assert!( - table_names - .iter() - .any(|name| name == "signer_connection_pending_request") - ); - assert!( - table_names - .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, 3); - - let connection_columns = query_values(&db, "PRAGMA table_info(signer_connection)"); - assert!(connection_columns.iter().any(|row| { - row.get("name").and_then(Value::as_str) == Some("client_metadata_json") - })); - - let store_version = query_single_i64( - &db, - "SELECT store_version FROM signer_store_metadata WHERE singleton_id = 1", - "store_version", - ); - assert_eq!(store_version, 1); - } - - #[test] - fn file_database_uses_wal_and_foreign_keys() { - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("signer.sqlite"); - { - let db = RadrootsNostrSignerSqliteDb::open(&path).expect("open sqlite file db"); - - assert_eq!( - query_single_text(&db, "PRAGMA main.journal_mode", "journal_mode"), - "wal" - ); - assert_eq!( - query_single_i64(&db, "PRAGMA foreign_keys", "foreign_keys"), - 1 - ); - } - - let reopened = RadrootsNostrSignerSqliteDb::open(&path).expect("reopen sqlite file db"); - assert_eq!( - query_single_text(&reopened, "PRAGMA main.journal_mode", "journal_mode"), - "wal" - ); - } - - #[test] - fn journal_mode_result_validation_fails_closed() { - assert!(matches!( - validate_journal_mode_result(r#"[{"journal_mode":"delete"}]"#, "wal"), - Err(RadrootsNostrSignerError::SqliteJournalModeMismatch { - expected: "wal", - actual, - }) if actual == "delete" - )); - assert!(matches!( - validate_journal_mode_result("[]", "wal"), - Err(RadrootsNostrSignerError::SqliteJournalModeResultCardinality { actual_rows: 0 }) - )); - assert!(matches!( - validate_journal_mode_result( - r#"[{"journal_mode":"wal"},{"journal_mode":"delete"}]"#, - "wal" - ), - Err(RadrootsNostrSignerError::SqliteJournalModeResultCardinality { actual_rows: 2 }) - )); - } - - #[test] - fn migrate_down_and_up_roundtrip_restores_schema() { - let db = RadrootsNostrSignerSqliteDb::open_memory().expect("open memory db"); - db.migrate_down().expect("migrate down"); - - let tables = query_values( - &db, - "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name", - ); - let table_names = tables - .into_iter() - .filter_map(|row| { - row.get("name") - .and_then(Value::as_str) - .map(ToOwned::to_owned) - }) - .collect::<Vec<_>>(); - assert_eq!(table_names, vec!["__migrations".to_owned()]); - - db.migrate_up().expect("migrate up again"); - let migration_count = query_single_i64( - &db, - "SELECT COUNT(*) AS applied_count FROM __migrations", - "applied_count", - ); - assert_eq!(migration_count, 3); - } -} diff --git a/src/signer/store.rs b/src/signer/store.rs @@ -1,1097 +0,0 @@ -use crate::signer::error::RadrootsNostrSignerError; -use crate::signer::model::RadrootsNostrSignerStoreState; -use serde::{Deserialize, de::DeserializeOwned}; -use serde_json::{Value, json}; -use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, RwLock}; - -use crate::host_identity::RadrootsIdentityPublic as PublicIdentity; -use crate::signer::model::{ - RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerApprovalState, - RadrootsNostrSignerAuthChallenge, RadrootsNostrSignerAuthState, - RadrootsNostrSignerConnectSecretHash, RadrootsNostrSignerConnectionRecord, - RadrootsNostrSignerConnectionStatus, RadrootsNostrSignerPendingRequest, - RadrootsNostrSignerPermissionGrant, RadrootsNostrSignerPublishWorkflowKind, - RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerPublishWorkflowState, - RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestDecision, -}; -use crate::signer::sqlite::RadrootsNostrSignerSqliteDb; -use crate::sql::SqlExecutor; -use nostr::RelayUrl; -use radroots_nostr_connect::{Method, Permission, message::RequestMessage, uri::ClientMetadata}; -use std::collections::BTreeMap; - -pub trait RadrootsNostrSignerStore: Send + Sync { - fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError>; - fn save(&self, state: &RadrootsNostrSignerStoreState) -> Result<(), RadrootsNostrSignerError>; -} - -#[derive(Debug, Clone)] -pub struct RadrootsNostrFileSignerStore { - path: PathBuf, -} - -#[derive(Debug, Clone, Default)] -pub struct RadrootsNostrMemorySignerStore { - state: Arc<RwLock<RadrootsNostrSignerStoreState>>, -} - -#[derive(Clone)] -pub struct RadrootsNostrSqliteSignerStore { - db: Arc<RadrootsNostrSignerSqliteDb>, -} - -impl RadrootsNostrFileSignerStore { - pub fn new(path: impl AsRef<Path>) -> Self { - Self { - path: path.as_ref().to_path_buf(), - } - } - - pub fn path(&self) -> &Path { - self.path.as_path() - } -} - -impl RadrootsNostrMemorySignerStore { - pub fn new() -> Self { - Self::default() - } -} - -impl RadrootsNostrSqliteSignerStore { - pub fn open(path: impl AsRef<Path>) -> Result<Self, RadrootsNostrSignerError> { - Ok(Self { - db: Arc::new(RadrootsNostrSignerSqliteDb::open(path)?), - }) - } - - pub fn open_memory() -> Result<Self, RadrootsNostrSignerError> { - Ok(Self { - db: Arc::new(RadrootsNostrSignerSqliteDb::open_memory()?), - }) - } -} - -impl RadrootsNostrSignerStore for RadrootsNostrFileSignerStore { - fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> { - if !self.path.exists() { - return Ok(RadrootsNostrSignerStoreState::default()); - } - let encoded = fs::read(self.path.as_path()) - .map_err(|_| RadrootsNostrSignerError::Store("read signer state".into()))?; - serde_json::from_slice(encoded.as_slice()).map_err(Into::into) - } - - fn save(&self, state: &RadrootsNostrSignerStoreState) -> Result<(), RadrootsNostrSignerError> { - if self.path.exists() { - let _ = self.load()?; - } - let parent = self - .path - .parent() - .filter(|path| !path.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); - fs::create_dir_all(parent) - .map_err(|_| RadrootsNostrSignerError::Store("create signer state directory".into()))?; - let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|_| { - RadrootsNostrSignerError::Store("create signer state temporary file".into()) - })?; - serde_json::to_writer_pretty(&mut temporary, state)?; - temporary - .write_all(b"\n") - .map_err(|_| RadrootsNostrSignerError::Store("write signer state".into()))?; - temporary - .as_file() - .sync_all() - .map_err(|_| RadrootsNostrSignerError::Store("sync signer state".into()))?; - set_private_file_permissions(temporary.as_file())?; - temporary - .persist(self.path.as_path()) - .map_err(|_| RadrootsNostrSignerError::Store("persist signer state".into()))?; - fs::File::open(parent) - .and_then(|directory| directory.sync_all()) - .map_err(|_| RadrootsNostrSignerError::Store("sync signer state directory".into()))?; - Ok(()) - } -} - -fn set_private_file_permissions(file: &fs::File) -> Result<(), RadrootsNostrSignerError> { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - file.set_permissions(fs::Permissions::from_mode(0o600)) - .map_err(|_| RadrootsNostrSignerError::Store("set signer state permissions".into())) - } - #[cfg(not(unix))] - { - let _ = file; - Ok(()) - } -} - -impl RadrootsNostrSignerStore for RadrootsNostrMemorySignerStore { - fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> { - let guard = self - .state - .read() - .map_err(|_| RadrootsNostrSignerError::Store("memory store lock poisoned".into()))?; - Ok(guard.clone()) - } - - fn save(&self, state: &RadrootsNostrSignerStoreState) -> Result<(), RadrootsNostrSignerError> { - let mut guard = self - .state - .write() - .map_err(|_| RadrootsNostrSignerError::Store("memory store lock poisoned".into()))?; - *guard = state.clone(); - Ok(()) - } -} - -impl RadrootsNostrSignerStore for RadrootsNostrSqliteSignerStore { - fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> { - let metadata_rows: Vec<SignerStoreMetadataRow> = query_rows( - self.db.as_ref(), - "SELECT store_version, signer_identity_json FROM signer_store_metadata WHERE singleton_id = 1", - )?; - let metadata = match metadata_rows.as_slice() { - [row] => row, - [] => { - return Err(RadrootsNostrSignerError::Store( - "sqlite signer metadata row missing".into(), - )); - } - _ => { - return Err(RadrootsNostrSignerError::Store( - "sqlite signer metadata row is not singular".into(), - )); - } - }; - - let mut state = RadrootsNostrSignerStoreState { - version: u32::try_from(metadata.store_version).map_err(|_| { - RadrootsNostrSignerError::Store(format!( - "sqlite signer store version {} is out of range", - metadata.store_version - )) - })?, - signer_identity: metadata - .signer_identity_json - .as_deref() - .map(parse_json_field::<PublicIdentity>) - .transpose()?, - connections: Vec::new(), - audit_records: Vec::new(), - publish_workflows: Vec::new(), - }; - - let connection_rows: Vec<SignerConnectionRow> = query_rows( - self.db.as_ref(), - "SELECT connection_id, client_public_key_hex, signer_identity_json, user_identity_json, connect_secret_hash_algorithm, connect_secret_hash_digest_hex, connect_secret_consumed_at_unix, requested_permissions_json, client_metadata_json, approval_requirement, approval_state, auth_state, status, status_reason, created_at_unix, updated_at_unix, last_authenticated_at_unix, last_request_at_unix FROM signer_connection ORDER BY created_at_unix, connection_id", - )?; - let mut connection_indexes = BTreeMap::new(); - for row in connection_rows { - let connection = row.into_record()?; - connection_indexes.insert( - connection.connection_id.as_str().to_owned(), - state.connections.len(), - ); - state.connections.push(connection); - } - - let permission_rows: Vec<SignerConnectionPermissionGrantRow> = query_rows( - self.db.as_ref(), - "SELECT connection_id, permission, granted_at_unix FROM signer_connection_permission_grant ORDER BY connection_id, granted_at_unix, permission", - )?; - for row in permission_rows { - let index = *connection_indexes - .get(row.connection_id.as_str()) - .ok_or_else(|| { - RadrootsNostrSignerError::Store(format!( - "permission grant row references missing connection `{}`", - row.connection_id - )) - })?; - state.connections[index] - .granted_permissions - .push(row.into_grant()?); - } - - let relay_rows: Vec<SignerConnectionRelayRow> = query_rows( - self.db.as_ref(), - "SELECT connection_id, relay_url FROM signer_connection_relay ORDER BY connection_id, ordinal", - )?; - for row in relay_rows { - let index = *connection_indexes - .get(row.connection_id.as_str()) - .ok_or_else(|| { - RadrootsNostrSignerError::Store(format!( - "relay row references missing connection `{}`", - row.connection_id - )) - })?; - state.connections[index].relays.push( - RelayUrl::parse(row.relay_url.as_str()) - .map_err(|error| RadrootsNostrSignerError::Store(error.to_string()))?, - ); - } - - let auth_rows: Vec<SignerConnectionAuthChallengeRow> = query_rows( - self.db.as_ref(), - "SELECT connection_id, auth_url, required_at_unix, authorized_at_unix FROM signer_connection_auth_challenge", - )?; - for row in auth_rows { - let index = *connection_indexes - .get(row.connection_id.as_str()) - .ok_or_else(|| { - RadrootsNostrSignerError::Store(format!( - "auth challenge row references missing connection `{}`", - row.connection_id - )) - })?; - state.connections[index].auth_challenge = Some( - RadrootsNostrSignerAuthChallenge::new(row.auth_url.as_str(), row.required_at_unix) - .map(|mut challenge| { - challenge.authorized_at_unix = row.authorized_at_unix; - challenge - })?, - ); - } - - let pending_rows: Vec<SignerConnectionPendingRequestRow> = query_rows( - self.db.as_ref(), - "SELECT connection_id, request_message_json, created_at_unix FROM signer_connection_pending_request", - )?; - for row in pending_rows { - let index = *connection_indexes - .get(row.connection_id.as_str()) - .ok_or_else(|| { - RadrootsNostrSignerError::Store(format!( - "pending request row references missing connection `{}`", - row.connection_id - )) - })?; - let request_message = - parse_json_field::<RequestMessage>(row.request_message_json.as_str())?; - state.connections[index].pending_request = Some( - RadrootsNostrSignerPendingRequest::new(request_message, row.created_at_unix)?, - ); - } - - let audit_rows: Vec<SignerRequestAuditRow> = query_rows( - self.db.as_ref(), - "SELECT request_id, connection_id, method, decision, message, created_at_unix FROM signer_request_audit ORDER BY created_at_unix, request_id", - )?; - state.audit_records = audit_rows - .into_iter() - .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) - } - - fn save(&self, state: &RadrootsNostrSignerStoreState) -> Result<(), RadrootsNostrSignerError> { - 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!([]))?; - - exec_json( - executor, - "INSERT INTO signer_store_metadata(singleton_id, store_version, signer_identity_id, signer_identity_public_key_hex, signer_identity_json, updated_at) VALUES(1, ?, ?, ?, ?, datetime('now')) ON CONFLICT(singleton_id) DO UPDATE SET store_version = excluded.store_version, signer_identity_id = excluded.signer_identity_id, signer_identity_public_key_hex = excluded.signer_identity_public_key_hex, signer_identity_json = excluded.signer_identity_json, updated_at = excluded.updated_at", - json!([ - i64::from(state.version), - state - .signer_identity - .as_ref() - .map(|identity| identity.id().to_string()), - state - .signer_identity - .as_ref() - .map(|identity| identity.public_key().to_hex()), - state - .signer_identity - .as_ref() - .map(serde_json::to_string) - .transpose()?, - ]), - )?; - - for connection in &state.connections { - exec_json( - executor, - "INSERT INTO signer_connection(connection_id, client_public_key_hex, signer_identity_id, signer_identity_public_key_hex, signer_identity_json, user_identity_id, user_identity_public_key_hex, user_identity_json, connect_secret_hash_algorithm, connect_secret_hash_digest_hex, connect_secret_consumed_at_unix, requested_permissions_json, client_metadata_json, approval_requirement, approval_state, auth_state, status, status_reason, created_at_unix, updated_at_unix, last_authenticated_at_unix, last_request_at_unix) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - json!([ - connection.connection_id.as_str(), - connection.client_public_key.to_hex(), - connection.signer_identity.id().to_string(), - connection.signer_identity.public_key().to_hex(), - serde_json::to_string(&connection.signer_identity)?, - connection.user_identity.id().to_string(), - connection.user_identity.public_key().to_hex(), - serde_json::to_string(&connection.user_identity)?, - connection - .connect_secret_hash - .as_ref() - .map(|hash| secret_digest_algorithm_label(hash)), - connection - .connect_secret_hash - .as_ref() - .map(|hash| hash.digest_hex.clone()), - connection.connect_secret_consumed_at_unix, - serde_json::to_string(&connection.requested_permissions)?, - connection - .client_metadata - .as_ref() - .map(serde_json::to_string) - .transpose()?, - approval_requirement_label(connection.approval_requirement), - approval_state_label(connection.approval_state), - auth_state_label(connection.auth_state), - connection_status_label(connection.status), - connection.status_reason.clone(), - connection.created_at_unix, - connection.updated_at_unix, - connection.last_authenticated_at_unix, - connection.last_request_at_unix, - ]), - )?; - - for grant in &connection.granted_permissions { - exec_json( - executor, - "INSERT INTO signer_connection_permission_grant(connection_id, permission, granted_at_unix) VALUES(?, ?, ?)", - json!([ - connection.connection_id.as_str(), - grant.permission.to_string(), - grant.granted_at_unix, - ]), - )?; - } - - for (ordinal, relay) in connection.relays.iter().enumerate() { - exec_json( - executor, - "INSERT INTO signer_connection_relay(connection_id, ordinal, relay_url) VALUES(?, ?, ?)", - json!([ - connection.connection_id.as_str(), - i64::try_from(ordinal).map_err(|_| { - RadrootsNostrSignerError::Store(format!( - "relay ordinal for connection `{}` is out of range", - connection.connection_id - )) - })?, - relay.as_str(), - ]), - )?; - } - - if let Some(challenge) = connection.auth_challenge.as_ref() { - exec_json( - executor, - "INSERT INTO signer_connection_auth_challenge(connection_id, auth_url, required_at_unix, authorized_at_unix) VALUES(?, ?, ?, ?)", - json!([ - connection.connection_id.as_str(), - challenge.auth_url, - challenge.required_at_unix, - challenge.authorized_at_unix, - ]), - )?; - } - - if let Some(pending_request) = connection.pending_request.as_ref() { - exec_json( - executor, - "INSERT INTO signer_connection_pending_request(connection_id, request_message_json, created_at_unix) VALUES(?, ?, ?)", - json!([ - connection.connection_id.as_str(), - serde_json::to_string(&pending_request.request_message)?, - pending_request.created_at_unix, - ]), - )?; - } - } - - for audit in &state.audit_records { - exec_json( - executor, - "INSERT INTO signer_request_audit(request_id, connection_id, method, decision, message, created_at_unix) VALUES(?, ?, ?, ?, ?, ?)", - json!([ - audit.request_id.as_str(), - audit.connection_id.as_str(), - audit.method.to_string(), - request_decision_label(audit.decision), - audit.message.clone(), - audit.created_at_unix, - ]), - )?; - } - - 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(()) - })(); - - match result { - Ok(()) => { - executor.commit()?; - Ok(()) - } - Err(error) => { - let _ = executor.rollback(); - Err(error) - } - } - } -} - -#[derive(Debug, Deserialize)] -struct SignerStoreMetadataRow { - store_version: i64, - signer_identity_json: Option<String>, -} - -#[derive(Debug, Deserialize)] -struct SignerConnectionRow { - connection_id: String, - client_public_key_hex: String, - signer_identity_json: String, - user_identity_json: String, - connect_secret_hash_algorithm: Option<String>, - connect_secret_hash_digest_hex: Option<String>, - connect_secret_consumed_at_unix: Option<u64>, - requested_permissions_json: String, - client_metadata_json: Option<String>, - approval_requirement: String, - approval_state: String, - auth_state: String, - status: String, - status_reason: Option<String>, - created_at_unix: u64, - updated_at_unix: u64, - last_authenticated_at_unix: Option<u64>, - last_request_at_unix: Option<u64>, -} - -impl SignerConnectionRow { - fn into_record(self) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - Ok(RadrootsNostrSignerConnectionRecord { - connection_id: self.connection_id.parse()?, - client_public_key: parse_public_key_hex(self.client_public_key_hex.as_str())?, - signer_identity: parse_json_field(self.signer_identity_json.as_str())?, - user_identity: parse_json_field(self.user_identity_json.as_str())?, - connect_secret_hash: match ( - self.connect_secret_hash_algorithm.as_deref(), - self.connect_secret_hash_digest_hex, - ) { - (None, None) => None, - (Some(algorithm), Some(digest_hex)) => Some(RadrootsNostrSignerConnectSecretHash { - algorithm: parse_secret_digest_algorithm(algorithm)?, - digest_hex, - }), - _ => { - return Err(RadrootsNostrSignerError::Store( - "sqlite connection secret hash columns are inconsistent".into(), - )); - } - }, - connect_secret_consumed_at_unix: self.connect_secret_consumed_at_unix, - requested_permissions: parse_json_field(self.requested_permissions_json.as_str())?, - client_metadata: self - .client_metadata_json - .as_deref() - .map(parse_json_field::<ClientMetadata>) - .transpose()?, - granted_permissions: Vec::new(), - relays: Vec::new(), - approval_requirement: parse_approval_requirement(self.approval_requirement.as_str())?, - approval_state: parse_approval_state(self.approval_state.as_str())?, - auth_state: parse_auth_state(self.auth_state.as_str())?, - auth_challenge: None, - pending_request: None, - status: parse_connection_status(self.status.as_str())?, - status_reason: self.status_reason, - created_at_unix: self.created_at_unix, - updated_at_unix: self.updated_at_unix, - last_authenticated_at_unix: self.last_authenticated_at_unix, - last_request_at_unix: self.last_request_at_unix, - }) - } -} - -#[derive(Debug, Deserialize)] -struct SignerConnectionPermissionGrantRow { - connection_id: String, - permission: String, - granted_at_unix: u64, -} - -impl SignerConnectionPermissionGrantRow { - fn into_grant(self) -> Result<RadrootsNostrSignerPermissionGrant, RadrootsNostrSignerError> { - Ok(RadrootsNostrSignerPermissionGrant { - permission: self - .permission - .parse::<Permission>() - .map_err(|error| RadrootsNostrSignerError::Store(error.to_string()))?, - granted_at_unix: self.granted_at_unix, - }) - } -} - -#[derive(Debug, Deserialize)] -struct SignerConnectionRelayRow { - connection_id: String, - relay_url: String, -} - -#[derive(Debug, Deserialize)] -struct SignerConnectionAuthChallengeRow { - connection_id: String, - auth_url: String, - required_at_unix: u64, - authorized_at_unix: Option<u64>, -} - -#[derive(Debug, Deserialize)] -struct SignerConnectionPendingRequestRow { - connection_id: String, - request_message_json: String, - created_at_unix: u64, -} - -#[derive(Debug, Deserialize)] -struct SignerRequestAuditRow { - request_id: String, - connection_id: String, - method: String, - decision: String, - message: Option<String>, - created_at_unix: u64, -} - -impl SignerRequestAuditRow { - fn into_record( - self, - ) -> Result<RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerError> { - Ok(RadrootsNostrSignerRequestAuditRecord { - request_id: self.request_id.parse()?, - connection_id: self.connection_id.parse()?, - method: self - .method - .parse::<Method>() - .map_err(|error| RadrootsNostrSignerError::Store(error.to_string()))?, - decision: parse_request_decision(self.decision.as_str())?, - message: self.message, - created_at_unix: self.created_at_unix, - }) - } -} - -#[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, -} - -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, - }) - } -} - -fn query_rows<T: DeserializeOwned>( - db: &RadrootsNostrSignerSqliteDb, - sql: &str, -) -> Result<Vec<T>, RadrootsNostrSignerError> { - let raw = db.executor().query_raw(sql, "[]")?; - serde_json::from_str(&raw).map_err(|error| RadrootsNostrSignerError::Store(error.to_string())) -} - -fn exec_json( - executor: &impl crate::sql::SqlExecutor, - sql: &str, - params: Value, -) -> Result<(), RadrootsNostrSignerError> { - let _ = executor.exec(sql, params.to_string().as_str())?; - Ok(()) -} - -fn parse_json_field<T: DeserializeOwned>(value: &str) -> Result<T, RadrootsNostrSignerError> { - serde_json::from_str(value).map_err(|error| RadrootsNostrSignerError::Store(error.to_string())) -} - -fn parse_public_key_hex(value: &str) -> Result<nostr::PublicKey, RadrootsNostrSignerError> { - nostr::PublicKey::parse(value) - .or_else(|_| nostr::PublicKey::from_hex(value)) - .map_err(|error| RadrootsNostrSignerError::Store(error.to_string())) -} - -fn approval_requirement_label(value: RadrootsNostrSignerApprovalRequirement) -> &'static str { - match value { - RadrootsNostrSignerApprovalRequirement::NotRequired => "not_required", - RadrootsNostrSignerApprovalRequirement::ExplicitUser => "explicit_user", - } -} - -fn parse_approval_requirement( - value: &str, -) -> Result<RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerError> { - match value { - "not_required" => Ok(RadrootsNostrSignerApprovalRequirement::NotRequired), - "explicit_user" => Ok(RadrootsNostrSignerApprovalRequirement::ExplicitUser), - other => Err(RadrootsNostrSignerError::Store(format!( - "unknown sqlite approval requirement `{other}`" - ))), - } -} - -fn approval_state_label(value: RadrootsNostrSignerApprovalState) -> &'static str { - match value { - RadrootsNostrSignerApprovalState::NotRequired => "not_required", - RadrootsNostrSignerApprovalState::Pending => "pending", - RadrootsNostrSignerApprovalState::Approved => "approved", - RadrootsNostrSignerApprovalState::Rejected => "rejected", - } -} - -fn parse_approval_state( - value: &str, -) -> Result<RadrootsNostrSignerApprovalState, RadrootsNostrSignerError> { - match value { - "not_required" => Ok(RadrootsNostrSignerApprovalState::NotRequired), - "pending" => Ok(RadrootsNostrSignerApprovalState::Pending), - "approved" => Ok(RadrootsNostrSignerApprovalState::Approved), - "rejected" => Ok(RadrootsNostrSignerApprovalState::Rejected), - other => Err(RadrootsNostrSignerError::Store(format!( - "unknown sqlite approval state `{other}`" - ))), - } -} - -fn auth_state_label(value: RadrootsNostrSignerAuthState) -> &'static str { - match value { - RadrootsNostrSignerAuthState::NotRequired => "not_required", - RadrootsNostrSignerAuthState::Pending => "pending", - RadrootsNostrSignerAuthState::Authorized => "authorized", - } -} - -fn parse_auth_state(value: &str) -> Result<RadrootsNostrSignerAuthState, RadrootsNostrSignerError> { - match value { - "not_required" => Ok(RadrootsNostrSignerAuthState::NotRequired), - "pending" => Ok(RadrootsNostrSignerAuthState::Pending), - "authorized" => Ok(RadrootsNostrSignerAuthState::Authorized), - other => Err(RadrootsNostrSignerError::Store(format!( - "unknown sqlite auth state `{other}`" - ))), - } -} - -fn connection_status_label(value: RadrootsNostrSignerConnectionStatus) -> &'static str { - match value { - RadrootsNostrSignerConnectionStatus::Pending => "pending", - RadrootsNostrSignerConnectionStatus::Active => "active", - RadrootsNostrSignerConnectionStatus::Rejected => "rejected", - RadrootsNostrSignerConnectionStatus::Revoked => "revoked", - } -} - -fn parse_connection_status( - value: &str, -) -> Result<RadrootsNostrSignerConnectionStatus, RadrootsNostrSignerError> { - match value { - "pending" => Ok(RadrootsNostrSignerConnectionStatus::Pending), - "active" => Ok(RadrootsNostrSignerConnectionStatus::Active), - "rejected" => Ok(RadrootsNostrSignerConnectionStatus::Rejected), - "revoked" => Ok(RadrootsNostrSignerConnectionStatus::Revoked), - other => Err(RadrootsNostrSignerError::Store(format!( - "unknown sqlite connection status `{other}`" - ))), - } -} - -fn request_decision_label(value: RadrootsNostrSignerRequestDecision) -> &'static str { - match value { - RadrootsNostrSignerRequestDecision::Allowed => "allowed", - RadrootsNostrSignerRequestDecision::Denied => "denied", - RadrootsNostrSignerRequestDecision::Challenged => "challenged", - } -} - -fn parse_request_decision( - value: &str, -) -> Result<RadrootsNostrSignerRequestDecision, RadrootsNostrSignerError> { - match value { - "allowed" => Ok(RadrootsNostrSignerRequestDecision::Allowed), - "denied" => Ok(RadrootsNostrSignerRequestDecision::Denied), - "challenged" => Ok(RadrootsNostrSignerRequestDecision::Challenged), - other => Err(RadrootsNostrSignerError::Store(format!( - "unknown sqlite request decision `{other}`" - ))), - } -} - -fn publish_workflow_kind_label(value: RadrootsNostrSignerPublishWorkflowKind) -> &'static str { - match value { - RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization => { - "connect_secret_finalization" - } - RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization => { - "auth_replay_finalization" - } - } -} - -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}`" - ))), - } -} - -fn publish_workflow_state_label(value: RadrootsNostrSignerPublishWorkflowState) -> &'static str { - match value { - RadrootsNostrSignerPublishWorkflowState::PendingPublish => "pending_publish", - RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize => { - "published_pending_finalize" - } - } -} - -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}`" - ))), - } -} - -fn secret_digest_algorithm_label(hash: &RadrootsNostrSignerConnectSecretHash) -> &'static str { - match hash.algorithm { - crate::signer::model::RadrootsNostrSignerSecretDigestAlgorithm::Sha256 => "sha256", - } -} - -fn parse_secret_digest_algorithm( - value: &str, -) -> Result<crate::signer::model::RadrootsNostrSignerSecretDigestAlgorithm, RadrootsNostrSignerError> -{ - match value { - "sha256" => Ok(crate::signer::model::RadrootsNostrSignerSecretDigestAlgorithm::Sha256), - other => Err(RadrootsNostrSignerError::Store(format!( - "unknown sqlite secret digest algorithm `{other}`" - ))), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::signer::model::{ - RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerAuthChallenge, - RadrootsNostrSignerAuthState, RadrootsNostrSignerConnectionDraft, - RadrootsNostrSignerConnectionId, RadrootsNostrSignerPendingRequest, - RadrootsNostrSignerPermissionGrant, RadrootsNostrSignerPublishWorkflowRecord, - RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestDecision, - RadrootsNostrSignerRequestId, - }; - use crate::signer::test_support::{ - api_primary_https, fixture_alice_identity, fixture_bob_identity, fixture_carol_public_key, - primary_relay, secondary_relay, - }; - use radroots_nostr_connect::{ - Method, Permission, Request, message::RequestMessage, permission::Permissions, - uri::ClientMetadata, - }; - use std::thread; - - #[test] - fn production_source_has_no_dead_code_allowance() { - let forbidden = ["#[allow(", "dead_code", ")]"].concat(); - assert!(!include_str!("store.rs").contains(forbidden.as_str())); - } - - #[test] - fn file_store_round_trip_and_path_accessor() { - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("signer.json"); - let store = RadrootsNostrFileSignerStore::new(path.as_path()); - - assert_eq!(store.path(), path.as_path()); - store - .save(&RadrootsNostrSignerStoreState::default()) - .expect("save"); - let loaded = store.load().expect("load"); - assert_eq!( - loaded.version, - RadrootsNostrSignerStoreState::default().version - ); - assert!(loaded.connections.is_empty()); - } - - #[test] - fn file_store_load_missing_and_reports_parse_errors() { - let temp = tempfile::tempdir().expect("tempdir"); - let missing = RadrootsNostrFileSignerStore::new(temp.path().join("missing.json")); - let loaded = missing.load().expect("missing load"); - assert!(loaded.connections.is_empty()); - - let path = temp.path().join("invalid.json"); - std::fs::write(&path, "{").expect("write invalid json"); - let store = RadrootsNostrFileSignerStore::new(path.as_path()); - let err = store.load().expect_err("invalid json"); - assert!(err.to_string().starts_with("store error:")); - } - - #[test] - fn file_store_save_reports_parse_error() { - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("invalid-save.json"); - std::fs::write(&path, "{").expect("write invalid json"); - let store = RadrootsNostrFileSignerStore::new(path.as_path()); - let err = store - .save(&RadrootsNostrSignerStoreState::default()) - .expect_err("invalid save"); - assert!(err.to_string().starts_with("store error:")); - } - - #[cfg(unix)] - #[test] - fn file_store_save_reports_write_error() { - use std::os::unix::fs::PermissionsExt; - - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("signer.json"); - let json = - serde_json::to_string(&RadrootsNostrSignerStoreState::default()).expect("serialize"); - std::fs::write(&path, json).expect("write json"); - let store = RadrootsNostrFileSignerStore::new(path.as_path()); - - let mut perms = std::fs::metadata(temp.path()) - .expect("dir metadata") - .permissions(); - perms.set_mode(0o500); - std::fs::set_permissions(temp.path(), perms).expect("set perms"); - - let err = store - .save(&RadrootsNostrSignerStoreState::default()) - .expect_err("read-only save"); - assert!(err.to_string().starts_with("store error:")); - - let mut perms = std::fs::metadata(temp.path()) - .expect("dir metadata") - .permissions(); - perms.set_mode(0o700); - std::fs::set_permissions(temp.path(), perms).expect("restore perms"); - } - - #[test] - fn memory_store_round_trip_and_poison_errors() { - let store = RadrootsNostrMemorySignerStore::new(); - let state = RadrootsNostrSignerStoreState::default(); - store.save(&state).expect("save"); - let loaded = store.load().expect("load"); - assert_eq!(loaded.version, state.version); - - let shared = store.state.clone(); - let _ = thread::spawn(move || { - let _guard = shared.write().expect("write"); - panic!("poison memory store"); - }) - .join(); - - let load = store.load().expect_err("poisoned load"); - let save = store.save(&state).expect_err("poisoned save"); - assert!(load.to_string().contains("memory store lock poisoned")); - assert!(save.to_string().contains("memory store lock poisoned")); - } - - fn sample_request_message(id: &str) -> RequestMessage { - RequestMessage::new(id, Request::Ping) - } - - fn sample_sqlite_state() -> RadrootsNostrSignerStoreState { - let signer_identity = fixture_alice_identity(); - let user_identity = fixture_bob_identity(); - let connection_id = RadrootsNostrSignerConnectionId::parse("conn-sqlite").expect("id"); - let mut connection = RadrootsNostrSignerConnectionRecord::new( - connection_id.clone(), - signer_identity.clone(), - RadrootsNostrSignerConnectionDraft::new(fixture_carol_public_key(), user_identity) - .with_connect_secret("sqlite-secret") - .with_client_metadata(ClientMetadata { - requested_permissions: Permissions::default(), - name: Some("Example Client".to_owned()), - url: Some("https://client.example.com/".to_owned()), - image: Some("https://client.example.com/icon.png".to_owned()), - }) - .with_relays(vec![primary_relay(), secondary_relay()]) - .with_requested_permissions( - vec![ - Permission::new(Method::Ping), - Permission::with_parameter(Method::SignEvent, "kind:1"), - ] - .into(), - ) - .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser), - 100, - ); - connection.approval_state = - crate::signer::model::RadrootsNostrSignerApprovalState::Approved; - connection.auth_state = RadrootsNostrSignerAuthState::Pending; - connection.status = crate::signer::model::RadrootsNostrSignerConnectionStatus::Active; - connection.status_reason = Some("approved by operator".to_owned()); - connection.updated_at_unix = 140; - connection.last_authenticated_at_unix = Some(130); - connection.last_request_at_unix = Some(135); - connection.mark_connect_secret_consumed(125); - connection.granted_permissions = vec![ - RadrootsNostrSignerPermissionGrant::new(Permission::new(Method::Ping), 110), - RadrootsNostrSignerPermissionGrant::new( - Permission::with_parameter(Method::SignEvent, "kind:1"), - 111, - ), - ]; - connection.auth_challenge = Some( - RadrootsNostrSignerAuthChallenge::new( - format!("{}/challenge", api_primary_https()).as_str(), - 120, - ) - .expect("challenge"), - ); - connection.pending_request = Some( - RadrootsNostrSignerPendingRequest::new(sample_request_message("req-sqlite"), 121) - .expect("pending request"), - ); - - RadrootsNostrSignerStoreState { - version: 1, - signer_identity: Some(signer_identity), - connections: vec![connection.clone()], - audit_records: vec![RadrootsNostrSignerRequestAuditRecord::new( - RadrootsNostrSignerRequestId::parse("audit-1").expect("request id"), - connection_id, - Method::Ping, - RadrootsNostrSignerRequestDecision::Allowed, - 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, - ), - ], - } - } - - #[test] - fn sqlite_store_round_trip_on_memory_backend() { - let store = RadrootsNostrSqliteSignerStore::open_memory().expect("open memory store"); - let state = sample_sqlite_state(); - - store.save(&state).expect("save sqlite state"); - let loaded = store.load().expect("load sqlite state"); - - assert_eq!( - serde_json::to_value(&loaded).expect("serialize loaded"), - serde_json::to_value(&state).expect("serialize state") - ); - } - - #[test] - fn sqlite_store_persists_to_disk_and_recovers_after_reopen() { - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("signer.sqlite"); - let state = sample_sqlite_state(); - - let store = RadrootsNostrSqliteSignerStore::open(&path).expect("open sqlite store"); - store.save(&state).expect("save sqlite state"); - - let reopened = RadrootsNostrSqliteSignerStore::open(&path).expect("reopen sqlite store"); - let loaded = reopened.load().expect("load reopened sqlite state"); - - assert_eq!( - serde_json::to_value(&loaded).expect("serialize loaded"), - serde_json::to_value(&state).expect("serialize state") - ); - } -} diff --git a/src/sql.rs b/src/sql.rs @@ -1,308 +0,0 @@ -//! Myc-owned synchronous SQLite adapter for service persistence. - -use std::path::Path; -use std::sync::{Arc, Mutex}; - -use serde::Serialize; -use serde_json::{Map, Value, json}; -use sqlx::sqlite::{SqliteArguments, SqliteConnectOptions, SqliteConnection, SqliteRow}; -use sqlx::{Column, Connection, Row, TypeInfo, ValueRef}; - -#[derive(Debug, Clone, Serialize)] -pub enum SqlError { - InvalidArgument(String), - NotFound(String), - SerializationError(String), - InvalidQuery(String), - Internal, - UnsupportedPlatform, -} - -impl std::fmt::Display for SqlError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::InvalidArgument(value) => write!(formatter, "invalid argument: {value}"), - Self::NotFound(value) => write!(formatter, "{value} not found"), - Self::SerializationError(value) => write!(formatter, "serialization error: {value}"), - Self::InvalidQuery(value) => write!(formatter, "invalid query: {value}"), - Self::Internal => formatter.write_str("internal error"), - Self::UnsupportedPlatform => formatter.write_str("unsupported on this platform"), - } - } -} - -impl std::error::Error for SqlError {} - -impl From<serde_json::Error> for SqlError { - fn from(error: serde_json::Error) -> Self { - Self::SerializationError(error.to_string()) - } -} - -impl From<sqlx::Error> for SqlError { - fn from(error: sqlx::Error) -> Self { - Self::InvalidQuery(error.to_string()) - } -} - -#[derive(Clone, Copy, Debug)] -pub struct ExecOutcome { - pub changes: i64, - pub last_insert_id: i64, -} - -pub trait SqlExecutor: Send + Sync { - fn exec(&self, sql: &str, params_json: &str) -> Result<ExecOutcome, SqlError>; - fn query_raw(&self, sql: &str, params_json: &str) -> Result<String, SqlError>; - fn begin(&self) -> Result<(), SqlError>; - fn commit(&self) -> Result<(), SqlError>; - fn rollback(&self) -> Result<(), SqlError>; -} - -impl<T> SqlExecutor for &T -where - T: SqlExecutor + ?Sized, -{ - fn exec(&self, sql: &str, params_json: &str) -> Result<ExecOutcome, SqlError> { - (**self).exec(sql, params_json) - } - - fn query_raw(&self, sql: &str, params_json: &str) -> Result<String, SqlError> { - (**self).query_raw(sql, params_json) - } - - fn begin(&self) -> Result<(), SqlError> { - (**self).begin() - } - - fn commit(&self) -> Result<(), SqlError> { - (**self).commit() - } - - fn rollback(&self) -> Result<(), SqlError> { - (**self).rollback() - } -} - -pub struct SqlxSqliteExecutor { - connection: Arc<Mutex<SqliteConnection>>, -} - -impl SqlxSqliteExecutor { - pub fn open(path: impl AsRef<Path>) -> Result<Self, SqlError> { - Self::connect( - SqliteConnectOptions::new() - .filename(path) - .create_if_missing(true), - ) - } - - pub fn open_memory() -> Result<Self, SqlError> { - Self::connect(SqliteConnectOptions::new().in_memory(true)) - } - - fn connect(options: SqliteConnectOptions) -> Result<Self, SqlError> { - let connection = futures_executor::block_on(SqliteConnection::connect_with(&options))?; - Ok(Self { - connection: Arc::new(Mutex::new(connection)), - }) - } -} - -impl SqlExecutor for SqlxSqliteExecutor { - fn exec(&self, sql: &str, params_json: &str) -> Result<ExecOutcome, SqlError> { - let binds = parse_params(params_json)?; - let mut connection = self.connection.lock().map_err(|_| SqlError::Internal)?; - if binds.is_empty() { - let result = futures_executor::block_on( - sqlx::raw_sql(sqlx::AssertSqlSafe(sql)).execute(&mut *connection), - )?; - return Ok(ExecOutcome { - changes: i64::try_from(result.rows_affected()).map_err(|_| SqlError::Internal)?, - last_insert_id: result.last_insert_rowid(), - }); - } - let query = bind_params(sqlx::query(sqlx::AssertSqlSafe(sql)), binds); - let result = futures_executor::block_on(query.execute(&mut *connection))?; - Ok(ExecOutcome { - changes: i64::try_from(result.rows_affected()).map_err(|_| SqlError::Internal)?, - last_insert_id: result.last_insert_rowid(), - }) - } - - fn query_raw(&self, sql: &str, params_json: &str) -> Result<String, SqlError> { - let query = bind_params( - sqlx::query(sqlx::AssertSqlSafe(sql)), - parse_params(params_json)?, - ); - let rows = { - let mut connection = self.connection.lock().map_err(|_| SqlError::Internal)?; - futures_executor::block_on(query.fetch_all(&mut *connection))? - }; - let rows = rows - .iter() - .map(row_to_json) - .collect::<Result<Vec<_>, _>>()?; - Ok(Value::from(rows).to_string()) - } - - fn begin(&self) -> Result<(), SqlError> { - self.exec_transaction("BEGIN") - } - - fn commit(&self) -> Result<(), SqlError> { - self.exec_transaction("COMMIT") - } - - fn rollback(&self) -> Result<(), SqlError> { - self.exec_transaction("ROLLBACK") - } -} - -impl SqlxSqliteExecutor { - fn exec_transaction(&self, statement: &str) -> Result<(), SqlError> { - let mut connection = self.connection.lock().map_err(|_| SqlError::Internal)?; - futures_executor::block_on( - sqlx::query(sqlx::AssertSqlSafe(statement)).execute(&mut *connection), - )?; - Ok(()) - } -} - -#[derive(Clone, Copy, Debug)] -pub struct Migration { - pub name: &'static str, - pub up_sql: &'static str, - pub down_sql: &'static str, -} - -pub fn migrations_run_all_up( - executor: &impl SqlExecutor, - migrations: &[Migration], -) -> Result<(), SqlError> { - ensure_migrations_table(executor)?; - for migration in migrations { - let rows: Vec<Value> = serde_json::from_str(&executor.query_raw( - "select 1 as applied from __migrations where name = ? limit 1", - &json!([migration.name]).to_string(), - )?)?; - if rows.is_empty() { - executor.begin()?; - let result = (|| { - executor.exec(migration.up_sql, "[]")?; - executor.exec( - "insert or ignore into __migrations(name) values(?)", - &json!([migration.name]).to_string(), - )?; - Ok::<_, SqlError>(()) - })(); - if let Err(error) = result { - let _ = executor.rollback(); - return Err(error); - } - executor.commit()?; - } - } - Ok(()) -} - -pub fn migrations_run_all_down( - executor: &impl SqlExecutor, - migrations: &[Migration], -) -> Result<(), SqlError> { - ensure_migrations_table(executor)?; - executor.begin()?; - for migration in migrations.iter().rev() { - executor.exec( - "delete from __migrations where name = ?", - &json!([migration.name]).to_string(), - )?; - executor.exec(migration.down_sql, "[]")?; - } - executor.commit() -} - -fn ensure_migrations_table(executor: &impl SqlExecutor) -> Result<(), SqlError> { - executor.exec( - "create table if not exists __migrations(id integer primary key, name text not null unique, applied_at text not null default (datetime('now')))", - "[]", - )?; - Ok(()) -} - -#[derive(Debug)] -enum BindValue { - Null, - Integer(i64), - Real(f64), - Text(String), -} - -fn parse_params(params_json: &str) -> Result<Vec<BindValue>, SqlError> { - serde_json::from_str::<Vec<Value>>(params_json)? - .into_iter() - .map(|value| match value { - Value::Null => Ok(BindValue::Null), - Value::Bool(value) => Ok(BindValue::Integer(i64::from(value))), - Value::Number(value) if value.is_i64() => { - Ok(BindValue::Integer(value.as_i64().expect("checked"))) - } - Value::Number(value) if value.is_u64() => value - .as_u64() - .and_then(|value| i64::try_from(value).ok()) - .map(BindValue::Integer) - .ok_or_else(|| SqlError::InvalidArgument("integer bind exceeds i64".into())), - Value::Number(value) => value - .as_f64() - .map(BindValue::Real) - .ok_or_else(|| SqlError::InvalidArgument("unsupported number".into())), - Value::String(value) => Ok(BindValue::Text(value)), - _ => Err(SqlError::InvalidArgument("unsupported bind value".into())), - }) - .collect() -} - -fn bind_params<'q>( - mut query: sqlx::query::Query<'q, sqlx::Sqlite, SqliteArguments>, - params: Vec<BindValue>, -) -> sqlx::query::Query<'q, sqlx::Sqlite, SqliteArguments> { - for param in params { - query = match param { - BindValue::Null => query.bind(Option::<String>::None), - BindValue::Integer(value) => query.bind(value), - BindValue::Real(value) => query.bind(value), - BindValue::Text(value) => query.bind(value), - }; - } - query -} - -fn row_to_json(row: &SqliteRow) -> Result<Value, SqlError> { - let mut object = Map::new(); - for (index, column) in row.columns().iter().enumerate() { - let raw = row.try_get_raw(index)?; - let value = if raw.is_null() { - Value::Null - } else { - match raw.type_info().name() { - "INTEGER" | "BOOLEAN" => Value::from(row.try_get::<i64, _>(index)?), - "REAL" => Value::from(row.try_get::<f64, _>(index)?), - "TEXT" | "DATE" | "TIME" | "DATETIME" => { - Value::from(row.try_get::<String, _>(index)?) - } - "BLOB" => Value::Null, - other => return Err(SqlError::InvalidQuery(other.to_owned())), - } - }; - object.insert(column.name().to_owned(), value); - } - Ok(Value::Object(object)) -} - -pub mod error { - pub use super::SqlError; -} - -pub mod migrations { - pub use super::{Migration, migrations_run_all_down, migrations_run_all_up}; -} diff --git a/src/state_discovery.rs b/src/state_discovery.rs @@ -10,9 +10,6 @@ use serde::Serialize; use sha2::{Digest, Sha256}; use sqlx::Row; -use crate::nostr_contract::{ - RadrootsNostrEvent, RadrootsNostrKind, RadrootsNostrMetadata, RadrootsNostrRelayUrl, -}; use crate::state_delivery::{ DeliveryOperationError, MycDeliveryArtifactDigest, MycDeliveryJobId, MycDeliveryJobRecord, MycDeliveryJobStatus, MycDeliverySource, MycDeliveryTimeUnixMs, create_job, @@ -22,6 +19,10 @@ use crate::state_repository::{ RepositoryOperationError, require_expected_metadata, }; use crate::{MycExpectedIdentities, MycStateMetadata}; +use nostr::RelayUrl as RadrootsNostrRelayUrl; +use radroots_nostr::event::{ + Event as RadrootsNostrEvent, Kind as RadrootsNostrKind, Metadata as RadrootsNostrMetadata, +}; /// Maximum exact signed-event bytes admitted from the configured event bound. pub const MYC_DISCOVERY_DOCUMENT_MAX_BYTES: usize = 524_288; diff --git a/src/state_metadata.rs b/src/state_metadata.rs @@ -17,9 +17,8 @@ use crate::state_governance::{ MycGovernancePolicies, MycRateLimitClass, MycRateLimitPolicy, MycRateRelayId, }; use crate::{ - MYC_CONFIG_SCHEMA_VERSION, MYC_SIGNER_STATUS_CONTRACT_VERSION, MYC_STATE_BASE_SCHEMA_VERSION, - MYC_STATE_SCHEMA_VERSION, MycBootstrapProfileV1, MycConfigDocumentV1, MycConfigProfile, - MycRuntimeContext, + MYC_CONFIG_SCHEMA_VERSION, MYC_STATE_BASE_SCHEMA_VERSION, MYC_STATE_SCHEMA_VERSION, + MycBootstrapProfileV1, MycConfigDocumentV1, MycConfigProfile, MycRuntimeContext, }; const NORMALIZED_CONFIG_DIGEST_DOMAIN: &[u8] = b"radroots.myc.normalized_config.v1\0"; @@ -29,6 +28,8 @@ pub const MYC_STATE_APPLICATION_ID: u32 = 0x5244_4d59; /// Exact version of the governed Myc operator contract. pub const MYC_OPERATOR_CONTRACT_VERSION: u32 = 1; +/// Exact version of the governed Myc status contract. +pub const MYC_SIGNER_STATUS_CONTRACT_VERSION: u32 = 1; /// SHA-256 identity of one fully defaulted normalized Myc configuration. #[derive(Clone, Copy, PartialEq, Eq, Hash)] diff --git a/src/transport.rs b/src/transport.rs @@ -1,715 +0,0 @@ -pub mod nip46; - -use std::collections::{BTreeMap, BTreeSet}; -use std::time::Duration; - -use crate::nostr_contract::{ - RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrGenericEventBuilder, RadrootsNostrOutput, - RadrootsNostrRelayUrl, -}; -use serde::Serialize; -use tokio::time::sleep; - -use crate::config::{MycTransportConfig, MycTransportDeliveryPolicy}; -use crate::custody::MycActiveIdentity; -use crate::error::MycError; - -pub use nip46::{MycNip46Handler, MycNip46Service}; - -#[derive(Clone)] -pub struct MycNostrTransport { - client: RadrootsNostrClient, - relays: Vec<RadrootsNostrRelayUrl>, - connect_timeout_secs: u64, - delivery_policy: MycTransportDeliveryPolicy, - delivery_quorum: Option<usize>, - publish_max_attempts: usize, - publish_initial_backoff_millis: u64, - publish_max_backoff_millis: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycTransportSnapshot { - pub enabled: bool, - pub relay_count: usize, - pub connect_timeout_secs: u64, - pub delivery_policy: MycTransportDeliveryPolicy, - pub delivery_quorum: Option<usize>, - pub publish_max_attempts: usize, - pub publish_initial_backoff_millis: u64, - pub publish_max_backoff_millis: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MycPublishOutcome { - pub relay_count: usize, - pub acknowledged_relay_count: usize, - pub required_acknowledged_relay_count: usize, - pub delivery_policy: MycTransportDeliveryPolicy, - pub attempt_count: usize, - pub relay_outcome_summary: String, - pub relay_results: Vec<MycRelayPublishResult>, - pub attempt_summaries: Vec<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycRelayPublishResult { - pub relay_url: String, - pub acknowledged: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub detail: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct MycPublishSettings { - delivery_policy: MycTransportDeliveryPolicy, - delivery_quorum: Option<usize>, - publish_max_attempts: usize, - publish_initial_backoff_millis: u64, - publish_max_backoff_millis: u64, -} - -impl MycNostrTransport { - pub fn bootstrap( - config: &MycTransportConfig, - signer_identity: &MycActiveIdentity, - ) -> Result<Option<Self>, MycError> { - if !config.enabled { - return Ok(None); - } - - Ok(Some(Self { - client: signer_identity.nostr_client(), - relays: config.parse_relays()?, - connect_timeout_secs: config.connect_timeout_secs, - delivery_policy: config.delivery_policy, - delivery_quorum: config.delivery_quorum, - publish_max_attempts: config.publish_max_attempts, - publish_initial_backoff_millis: config.publish_initial_backoff_millis, - publish_max_backoff_millis: config.publish_max_backoff_millis, - })) - } - - pub fn client(&self) -> &RadrootsNostrClient { - &self.client - } - - pub fn relays(&self) -> &[RadrootsNostrRelayUrl] { - self.relays.as_slice() - } - - pub fn connect_timeout_secs(&self) -> u64 { - self.connect_timeout_secs - } - - pub fn delivery_policy(&self) -> MycTransportDeliveryPolicy { - self.delivery_policy - } - - pub async fn connect(&self) -> Result<(), MycError> { - for relay in &self.relays { - let _ = self.client.add_relay(relay.as_str()).await?; - } - self.client.connect().await; - self.client - .wait_for_connection(Duration::from_secs(self.connect_timeout_secs)) - .await; - Ok(()) - } - - pub async fn publish_once( - signer_identity: &MycActiveIdentity, - relays: &[RadrootsNostrRelayUrl], - config: &MycTransportConfig, - operation: &str, - event: RadrootsNostrGenericEventBuilder, - ) -> Result<MycPublishOutcome, MycError> { - if relays.is_empty() { - return Err(MycError::InvalidOperation( - "cannot publish without at least one relay".to_owned(), - )); - } - - let event = signer_identity.sign_protocol_event_builder(event, "publish")?; - Self::publish_event_once(signer_identity, relays, config, operation, &event).await - } - - pub async fn publish_event_once( - signer_identity: &MycActiveIdentity, - relays: &[RadrootsNostrRelayUrl], - config: &MycTransportConfig, - operation: &str, - event: &RadrootsNostrEvent, - ) -> Result<MycPublishOutcome, MycError> { - if relays.is_empty() { - return Err(MycError::InvalidOperation( - "cannot publish without at least one relay".to_owned(), - )); - } - - let settings = MycPublishSettings::from_config(config); - publish_with_policy(relays, &settings, operation, || async { - let client = signer_identity.nostr_client(); - for relay in relays { - client - .add_relay(relay.as_str()) - .await - .map_err(|error| error.to_string())?; - } - client.connect().await; - client - .wait_for_connection(Duration::from_secs(config.connect_timeout_secs)) - .await; - client - .send_event(event) - .await - .map_err(|error| error.to_string()) - }) - .await - } - - pub async fn publish_event( - &self, - operation: &str, - event: &RadrootsNostrEvent, - ) -> Result<MycPublishOutcome, MycError> { - publish_with_policy( - self.relays(), - &self.publish_settings(), - operation, - || async { - self.client - .send_event(event) - .await - .map_err(|error| error.to_string()) - }, - ) - .await - } - - pub fn snapshot(&self) -> MycTransportSnapshot { - MycTransportSnapshot { - enabled: true, - relay_count: self.relays.len(), - connect_timeout_secs: self.connect_timeout_secs, - delivery_policy: self.delivery_policy, - delivery_quorum: self.delivery_quorum, - publish_max_attempts: self.publish_max_attempts, - publish_initial_backoff_millis: self.publish_initial_backoff_millis, - publish_max_backoff_millis: self.publish_max_backoff_millis, - } - } - - fn publish_settings(&self) -> MycPublishSettings { - MycPublishSettings { - delivery_policy: self.delivery_policy, - delivery_quorum: self.delivery_quorum, - publish_max_attempts: self.publish_max_attempts, - publish_initial_backoff_millis: self.publish_initial_backoff_millis, - publish_max_backoff_millis: self.publish_max_backoff_millis, - } - } -} - -async fn publish_with_policy<T, F, Fut>( - relays: &[RadrootsNostrRelayUrl], - settings: &MycPublishSettings, - operation: &str, - mut send_attempt: F, -) -> Result<MycPublishOutcome, MycError> -where - T: std::fmt::Debug, - F: FnMut() -> Fut, - Fut: std::future::Future<Output = Result<RadrootsNostrOutput<T>, String>>, -{ - let relay_count = relays.len(); - let required_acknowledged_relay_count = - settings.required_acknowledged_relay_count(relay_count)?; - let mut attempt_results = Vec::new(); - - for attempt_number in 1..=settings.publish_max_attempts { - let attempt = match send_attempt().await { - Ok(output) => build_publish_attempt_result(relays, attempt_number, &output), - Err(error) => build_failed_publish_attempt_result(relays, attempt_number, error), - }; - let threshold_reached = - attempt.acknowledged_relay_count >= required_acknowledged_relay_count; - attempt_results.push(attempt); - - if threshold_reached { - let final_attempt = attempt_results - .last() - .expect("publish attempt results contain the successful attempt"); - return Ok(MycPublishOutcome { - relay_count, - acknowledged_relay_count: final_attempt.acknowledged_relay_count, - required_acknowledged_relay_count, - delivery_policy: settings.delivery_policy, - attempt_count: attempt_results.len(), - relay_outcome_summary: summarize_delivery_policy_result( - settings.delivery_policy, - required_acknowledged_relay_count, - &attempt_results, - ), - relay_results: final_attempt.relay_results.clone(), - attempt_summaries: attempt_results - .iter() - .map(|attempt| attempt.relay_outcome_summary.clone()) - .collect(), - }); - } - - if attempt_number < settings.publish_max_attempts { - sleep(Duration::from_millis( - settings.backoff_for_attempt(attempt_number), - )) - .await; - } - } - - let final_attempt = attempt_results - .last() - .expect("publish attempt results contain at least one attempt"); - Err(MycError::PublishRejected { - operation: operation.to_owned(), - relay_count, - acknowledged_relay_count: final_attempt.acknowledged_relay_count, - required_acknowledged_relay_count, - delivery_policy: settings.delivery_policy, - attempt_count: attempt_results.len(), - details: summarize_delivery_policy_result( - settings.delivery_policy, - required_acknowledged_relay_count, - &attempt_results, - ), - rejected_relays: final_attempt - .relay_results - .iter() - .filter(|result| !result.acknowledged) - .map(|result| result.relay_url.clone()) - .collect(), - }) -} - -fn build_publish_relay_results<T>( - relays: &[RadrootsNostrRelayUrl], - output: &RadrootsNostrOutput<T>, -) -> Vec<MycRelayPublishResult> -where - T: std::fmt::Debug, -{ - let acknowledged_relays = output - .success - .iter() - .map(ToString::to_string) - .collect::<BTreeSet<_>>(); - let failed_relays = output - .failed - .iter() - .map(|(relay, error)| (relay.to_string(), error.to_string())) - .collect::<BTreeMap<_, _>>(); - - relays - .iter() - .map(|relay| { - let relay_url = relay.to_string(); - if acknowledged_relays.contains(&relay_url) { - MycRelayPublishResult { - relay_url, - acknowledged: true, - detail: None, - } - } else { - MycRelayPublishResult { - relay_url: relay_url.clone(), - acknowledged: false, - detail: Some( - failed_relays - .get(&relay_url) - .cloned() - .unwrap_or_else(|| "no relay acknowledgement reported".to_owned()), - ), - } - } - }) - .collect() -} - -fn build_publish_attempt_result<T>( - relays: &[RadrootsNostrRelayUrl], - attempt_number: usize, - output: &RadrootsNostrOutput<T>, -) -> MycPublishAttemptResult -where - T: std::fmt::Debug, -{ - let relay_results = build_publish_relay_results(relays, output); - let acknowledged_relay_count = relay_results - .iter() - .filter(|result| result.acknowledged) - .count(); - MycPublishAttemptResult { - attempt_number, - acknowledged_relay_count, - relay_outcome_summary: summarize_publish_results(&relay_results), - relay_results, - } -} - -fn build_failed_publish_attempt_result( - relays: &[RadrootsNostrRelayUrl], - attempt_number: usize, - error: String, -) -> MycPublishAttemptResult { - let relay_results = relays - .iter() - .map(|relay| MycRelayPublishResult { - relay_url: relay.to_string(), - acknowledged: false, - detail: Some(error.clone()), - }) - .collect::<Vec<_>>(); - MycPublishAttemptResult { - attempt_number, - acknowledged_relay_count: 0, - relay_outcome_summary: summarize_publish_results(&relay_results), - relay_results, - } -} - -fn summarize_publish_results(relay_results: &[MycRelayPublishResult]) -> String { - let relay_count = relay_results.len(); - let acknowledged_relay_count = relay_results - .iter() - .filter(|result| result.acknowledged) - .count(); - if relay_count == 0 { - return "no relay acknowledged the publish".to_owned(); - } - - let mut summary = - format!("{acknowledged_relay_count}/{relay_count} relays acknowledged publish"); - let acknowledged = relay_results - .iter() - .filter(|result| result.acknowledged) - .map(|result| result.relay_url.clone()) - .collect::<Vec<_>>(); - if !acknowledged.is_empty() { - summary.push_str("; acknowledged: "); - summary.push_str(&acknowledged.join(", ")); - } - let failures = relay_results - .iter() - .filter(|result| !result.acknowledged) - .map(|result| match result.detail.as_deref() { - Some(detail) => format!("{}: {detail}", result.relay_url), - None => result.relay_url.clone(), - }) - .collect::<Vec<_>>(); - if !failures.is_empty() { - summary.push_str("; failures: "); - summary.push_str(&failures.join("; ")); - } - summary -} - -fn summarize_delivery_policy_result( - delivery_policy: MycTransportDeliveryPolicy, - required_acknowledged_relay_count: usize, - attempt_results: &[MycPublishAttemptResult], -) -> String { - let attempt_count = attempt_results.len(); - let final_attempt = attempt_results - .last() - .expect("delivery policy summary requires at least one attempt"); - let mut summary = format!( - "delivery policy {} required {required_acknowledged_relay_count} acknowledgements across {attempt_count} attempt(s); final attempt {}: {}", - delivery_policy.as_str(), - final_attempt.attempt_number, - final_attempt.relay_outcome_summary, - ); - if attempt_results.len() > 1 { - let attempt_summaries = attempt_results - .iter() - .map(|attempt| { - format!( - "attempt {}: {}", - attempt.attempt_number, attempt.relay_outcome_summary - ) - }) - .collect::<Vec<_>>(); - summary.push_str("; "); - summary.push_str(&attempt_summaries.join(" | ")); - } - summary -} - -impl MycTransportSnapshot { - pub fn disabled() -> Self { - Self { - enabled: false, - relay_count: 0, - connect_timeout_secs: 0, - delivery_policy: MycTransportDeliveryPolicy::Any, - delivery_quorum: None, - publish_max_attempts: 1, - publish_initial_backoff_millis: 250, - publish_max_backoff_millis: 2_000, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct MycPublishAttemptResult { - attempt_number: usize, - acknowledged_relay_count: usize, - relay_outcome_summary: String, - relay_results: Vec<MycRelayPublishResult>, -} - -impl MycPublishSettings { - fn from_config(config: &MycTransportConfig) -> Self { - Self { - delivery_policy: config.delivery_policy, - delivery_quorum: config.delivery_quorum, - publish_max_attempts: config.publish_max_attempts, - publish_initial_backoff_millis: config.publish_initial_backoff_millis, - publish_max_backoff_millis: config.publish_max_backoff_millis, - } - } - - fn required_acknowledged_relay_count(&self, relay_count: usize) -> Result<usize, MycError> { - match self.delivery_policy { - MycTransportDeliveryPolicy::Any => Ok(1), - MycTransportDeliveryPolicy::All => Ok(relay_count), - MycTransportDeliveryPolicy::Quorum => { - let delivery_quorum = self.delivery_quorum.ok_or_else(|| { - MycError::InvalidConfig( - "transport.delivery_quorum must be set when transport.delivery_policy is `quorum`" - .to_owned(), - ) - })?; - if delivery_quorum > relay_count { - return Err(MycError::InvalidOperation(format!( - "transport.delivery_quorum `{delivery_quorum}` cannot be satisfied by `{relay_count}` target relays" - ))); - } - Ok(delivery_quorum) - } - } - } - - fn backoff_for_attempt(&self, completed_attempt_number: usize) -> u64 { - let exponent = completed_attempt_number.saturating_sub(1) as u32; - let scaled = self - .publish_initial_backoff_millis - .saturating_mul(2_u64.saturating_pow(exponent)); - scaled.min(self.publish_max_backoff_millis) - } -} - -#[cfg(test)] -mod tests { - use std::collections::{HashMap, HashSet}; - use std::sync::{Arc, Mutex}; - - use crate::nostr_contract::{RadrootsNostrEventId, RadrootsNostrOutput, RadrootsNostrRelayUrl}; - use tokio::time::Instant; - - use crate::config::{MycTransportConfig, MycTransportDeliveryPolicy}; - use crate::custody::MycActiveIdentity; - - use super::{MycNostrTransport, MycPublishSettings, MycTransportSnapshot, publish_with_policy}; - - fn signer_identity() -> MycActiveIdentity { - MycActiveIdentity::new( - crate::host_identity::RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"), - ) - } - - #[test] - fn bootstrap_returns_none_when_transport_disabled() { - let config = MycTransportConfig::default(); - - let transport = - MycNostrTransport::bootstrap(&config, &signer_identity()).expect("disabled transport"); - - assert!(transport.is_none()); - } - - #[test] - fn bootstrap_builds_transport_snapshot_when_enabled() { - let config = MycTransportConfig { - enabled: true, - connect_timeout_secs: 15, - relays: vec![ - "wss://relay.example.com".to_owned(), - "wss://relay2.example.com".to_owned(), - ], - delivery_policy: MycTransportDeliveryPolicy::Quorum, - delivery_quorum: Some(2), - publish_max_attempts: 3, - publish_initial_backoff_millis: 125, - publish_max_backoff_millis: 500, - }; - - let transport = MycNostrTransport::bootstrap(&config, &signer_identity()) - .expect("transport") - .expect("enabled transport"); - - assert_eq!(transport.relays().len(), 2); - assert_eq!(transport.connect_timeout_secs(), 15); - assert_eq!( - transport.snapshot(), - MycTransportSnapshot { - enabled: true, - relay_count: 2, - connect_timeout_secs: 15, - delivery_policy: MycTransportDeliveryPolicy::Quorum, - delivery_quorum: Some(2), - publish_max_attempts: 3, - publish_initial_backoff_millis: 125, - publish_max_backoff_millis: 500, - } - ); - } - - #[tokio::test] - async fn publish_with_policy_retries_until_threshold_is_met() { - let relays = vec![ - RadrootsNostrRelayUrl::parse("wss://relay-a.example.com").expect("relay-a"), - RadrootsNostrRelayUrl::parse("wss://relay-b.example.com").expect("relay-b"), - ]; - let settings = MycPublishSettings { - delivery_policy: MycTransportDeliveryPolicy::All, - delivery_quorum: None, - publish_max_attempts: 2, - publish_initial_backoff_millis: 10, - publish_max_backoff_millis: 10, - }; - let attempts = Arc::new(Mutex::new(vec![ - publish_output( - "1111111111111111111111111111111111111111111111111111111111111111", - &["wss://relay-a.example.com"], - &[("wss://relay-b.example.com", "blocked")], - ), - publish_output( - "2222222222222222222222222222222222222222222222222222222222222222", - &["wss://relay-a.example.com", "wss://relay-b.example.com"], - &[], - ), - ])); - - let start = Instant::now(); - let outcome = publish_with_policy(&relays, &settings, "test publish", || { - let attempts = Arc::clone(&attempts); - async move { - let output = attempts.lock().expect("attempts lock").remove(0); - Ok(output) - } - }) - .await - .expect("publish succeeds on retry"); - - assert_eq!(outcome.delivery_policy, MycTransportDeliveryPolicy::All); - assert_eq!(outcome.required_acknowledged_relay_count, 2); - assert_eq!(outcome.attempt_count, 2); - assert_eq!(outcome.acknowledged_relay_count, 2); - assert_eq!(outcome.relay_results.len(), 2); - assert_eq!(outcome.attempt_summaries.len(), 2); - assert!( - outcome - .relay_outcome_summary - .contains("delivery policy all") - ); - assert!(outcome.relay_outcome_summary.contains("attempt 1")); - assert!(start.elapsed() >= std::time::Duration::from_millis(10)); - } - - #[tokio::test] - async fn publish_with_policy_reports_threshold_failure() { - let relays = vec![ - RadrootsNostrRelayUrl::parse("wss://relay-a.example.com").expect("relay-a"), - RadrootsNostrRelayUrl::parse("wss://relay-b.example.com").expect("relay-b"), - ]; - let settings = MycPublishSettings { - delivery_policy: MycTransportDeliveryPolicy::Quorum, - delivery_quorum: Some(2), - publish_max_attempts: 2, - publish_initial_backoff_millis: 1, - publish_max_backoff_millis: 1, - }; - - let error = publish_with_policy::<RadrootsNostrEventId, _, _>( - &relays, - &settings, - "test publish", - || async { - Ok(publish_output( - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - &["wss://relay-a.example.com"], - &[("wss://relay-b.example.com", "blocked")], - )) - }, - ) - .await - .expect_err("quorum should fail without both acknowledgements"); - - assert_eq!( - error.publish_delivery_policy(), - Some(MycTransportDeliveryPolicy::Quorum) - ); - assert_eq!(error.publish_required_acknowledged_relay_count(), Some(2)); - assert_eq!(error.publish_attempt_count(), Some(2)); - assert!(error.to_string().contains("delivery policy quorum")); - } - - #[test] - fn publish_settings_reject_impossible_quorum_for_target_relays() { - let settings = MycPublishSettings { - delivery_policy: MycTransportDeliveryPolicy::Quorum, - delivery_quorum: Some(3), - publish_max_attempts: 1, - publish_initial_backoff_millis: 10, - publish_max_backoff_millis: 100, - }; - - let error = settings - .required_acknowledged_relay_count(2) - .expect_err("impossible quorum"); - assert!( - error - .to_string() - .contains("cannot be satisfied by `2` target relays") - ); - } - - fn publish_output( - event_id_hex: &str, - succeeded_relays: &[&str], - failed_relays: &[(&str, &str)], - ) -> RadrootsNostrOutput<RadrootsNostrEventId> { - let success = succeeded_relays - .iter() - .map(|relay| RadrootsNostrRelayUrl::parse(relay).expect("success relay")) - .collect::<HashSet<_>>(); - let failed = failed_relays - .iter() - .map(|(relay, error)| { - ( - RadrootsNostrRelayUrl::parse(relay).expect("failed relay"), - (*error).to_owned(), - ) - }) - .collect::<HashMap<_, _>>(); - - RadrootsNostrOutput { - val: RadrootsNostrEventId::parse(event_id_hex).expect("event id"), - success, - failed, - } - } -} diff --git a/src/transport/nip46.rs b/src/transport/nip46.rs @@ -1,2060 +0,0 @@ -use std::collections::{HashSet, VecDeque}; -use std::future::Future; -use std::sync::Arc; - -use crate::nostr_contract::{ - RadrootsNostrEvent, RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrPublicKey, - RadrootsNostrRelayPoolNotification, RadrootsNostrRelayUrl, -}; -use crate::signer::prelude::{ - RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionStatus, - RadrootsNostrSignerHandledRequest, RadrootsNostrSignerHandledRequestOutcome, - RadrootsNostrSignerNip46Handler, RadrootsNostrSignerNip46Signer, - RadrootsNostrSignerRequestDecision, RadrootsNostrSignerRequestEvaluation, - RadrootsNostrSignerRequestId, RadrootsNostrSignerSessionLookup, RadrootsNostrSignerWorkflowId, -}; -use radroots_nostr_connect::{Response, message::RPC_KIND, message::RequestMessage}; -use tokio::sync::broadcast; - -use crate::app::MycSignerContext; -use crate::app::backend::MycSignerBackend; -use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; -use crate::error::MycError; -use crate::outbox::{MycDeliveryOutboxKind, MycDeliveryOutboxRecord, MycDeliveryOutboxStore}; -use crate::transport::MycNostrTransport; - -type MycNip46CoreHandler = RadrootsNostrSignerNip46Handler< - MycSignerBackend, - crate::policy::MycPolicyContext, - MycNip46Signer, ->; - -#[derive(Clone)] -pub struct MycNip46Handler { - signer: MycSignerContext, - handler: MycNip46CoreHandler, -} - -pub struct MycNip46Service { - handler: MycNip46Handler, - transport: MycNostrTransport, - delivery_outbox_store: Arc<dyn MycDeliveryOutboxStore>, -} - -type MycNip46HandledOutcome = RadrootsNostrSignerHandledRequestOutcome; - -const NIP46_REPLAY_CACHE_CAPACITY: usize = 4_096; - -struct MycNip46ReplayGuard { - capacity: usize, - event_ids: HashSet<String>, - insertion_order: VecDeque<String>, -} - -impl MycNip46ReplayGuard { - fn new(capacity: usize) -> Self { - Self { - capacity, - event_ids: HashSet::with_capacity(capacity), - insertion_order: VecDeque::with_capacity(capacity), - } - } - - fn accept(&mut self, event_id: String) -> bool { - if self.capacity == 0 || !self.event_ids.insert(event_id.clone()) { - return false; - } - self.insertion_order.push_back(event_id); - while self.insertion_order.len() > self.capacity { - if let Some(expired) = self.insertion_order.pop_front() { - self.event_ids.remove(expired.as_str()); - } - } - true - } -} - -#[derive(Clone)] -struct MycNip46Signer { - signer: MycSignerContext, -} - -impl RadrootsNostrSignerNip46Signer for MycNip46Signer { - fn signer_public_key_hex(&self) -> String { - self.signer.signer_public_identity().public_key_hex - } - - fn decrypt_request( - &self, - client_public_key: &RadrootsNostrPublicKey, - ciphertext: &str, - ) -> Result<String, crate::signer::prelude::RadrootsNostrSignerError> { - self.signer - .signer_identity() - .nip44_decrypt(client_public_key, ciphertext) - .map_err(|error| { - crate::signer::prelude::RadrootsNostrSignerError::Sign(error.to_string()) - }) - } - - fn encrypt_response( - &self, - client_public_key: &RadrootsNostrPublicKey, - payload: &str, - ) -> Result<String, crate::signer::prelude::RadrootsNostrSignerError> { - self.signer - .signer_identity() - .nip44_encrypt(client_public_key, payload.to_owned()) - .map_err(|error| { - crate::signer::prelude::RadrootsNostrSignerError::Sign(error.to_string()) - }) - } - - fn user_identity(&self) -> crate::host_identity::RadrootsIdentityPublic { - self.signer.user_public_identity() - } - - fn sign_user_event( - &self, - unsigned_event: nostr::UnsignedEvent, - ) -> Result<RadrootsNostrEvent, crate::signer::prelude::RadrootsNostrSignerError> { - self.signer - .user_identity() - .sign_unsigned_event(unsigned_event, "managed user sign_event") - .map_err(|error| { - crate::signer::prelude::RadrootsNostrSignerError::Sign(error.to_string()) - }) - } - - fn nip04_encrypt( - &self, - public_key: &RadrootsNostrPublicKey, - plaintext: &str, - ) -> Result<String, crate::signer::prelude::RadrootsNostrSignerError> { - self.signer - .user_identity() - .nip04_encrypt(public_key, plaintext.to_owned()) - .map_err(|error| { - crate::signer::prelude::RadrootsNostrSignerError::Sign(error.to_string()) - }) - } - - fn nip04_decrypt( - &self, - public_key: &RadrootsNostrPublicKey, - ciphertext: &str, - ) -> Result<String, crate::signer::prelude::RadrootsNostrSignerError> { - self.signer - .user_identity() - .nip04_decrypt(public_key, ciphertext) - .map_err(|error| { - crate::signer::prelude::RadrootsNostrSignerError::Sign(error.to_string()) - }) - } - - fn nip44_encrypt( - &self, - public_key: &RadrootsNostrPublicKey, - plaintext: &str, - ) -> Result<String, crate::signer::prelude::RadrootsNostrSignerError> { - self.signer - .user_identity() - .nip44_encrypt(public_key, plaintext.to_owned()) - .map_err(|error| { - crate::signer::prelude::RadrootsNostrSignerError::Sign(error.to_string()) - }) - } - - fn nip44_decrypt( - &self, - public_key: &RadrootsNostrPublicKey, - ciphertext: &str, - ) -> Result<String, crate::signer::prelude::RadrootsNostrSignerError> { - self.signer - .user_identity() - .nip44_decrypt(public_key, ciphertext) - .map_err(|error| { - crate::signer::prelude::RadrootsNostrSignerError::Sign(error.to_string()) - }) - } -} - -impl MycNip46Handler { - pub fn new(signer: MycSignerContext, relays: Vec<RadrootsNostrRelayUrl>) -> Self { - let handler = RadrootsNostrSignerNip46Handler::new( - MycSignerBackend::new(signer.clone()), - signer.policy().clone(), - relays, - MycNip46Signer { - signer: signer.clone(), - }, - ); - Self { signer, handler } - } - - pub fn filter(&self) -> Result<RadrootsNostrFilter, MycError> { - self.handler.filter().map_err(Into::into) - } - - pub fn parse_request_event( - &self, - event: &RadrootsNostrEvent, - ) -> Result<RequestMessage, MycError> { - if event.kind != RadrootsNostrKind::Custom(RPC_KIND) { - return Err(MycError::InvalidOperation( - "NIP-46 request event has the wrong kind".to_owned(), - )); - } - event.verify().map_err(|_| { - MycError::InvalidOperation( - "NIP-46 request event has an invalid id or signature".to_owned(), - ) - })?; - - let signer_public_key = self.signer.signer_identity().public_key(); - let mut recipients = event.tags.public_keys(); - if recipients.next() != Some(&signer_public_key) || recipients.next().is_some() { - return Err(MycError::InvalidOperation( - "NIP-46 request event must have exactly one signer recipient".to_owned(), - )); - } - - let mut request_message = self.handler.parse_request_event(event)?; - request_message.id = - RadrootsNostrSignerRequestId::parse(request_message.id.as_str())?.into_string(); - Ok(request_message) - } - - pub fn build_response_event( - &self, - client_public_key: RadrootsNostrPublicKey, - request_id: impl Into<String>, - response: Response, - ) -> Result<crate::nostr_contract::RadrootsNostrGenericEventBuilder, MycError> { - self.handler - .build_response_event(client_public_key, request_id, response) - .map_err(Into::into) - } - - pub(crate) fn handle_request( - &self, - client_public_key: RadrootsNostrPublicKey, - request_message: RequestMessage, - ) -> Result<MycNip46HandledOutcome, MycError> { - if matches!( - &request_message.request, - radroots_nostr_connect::Request::Logout - ) { - return self.handle_logout_request(client_public_key, request_message); - } - self.handler - .handle_request(client_public_key, request_message) - .map_err(Into::into) - } - - fn handle_logout_request( - &self, - client_public_key: RadrootsNostrPublicKey, - request_message: RequestMessage, - ) -> Result<MycNip46HandledOutcome, MycError> { - let manager = self.signer.load_signer_manager()?; - let connection = match manager.lookup_session(&client_public_key, None)? { - RadrootsNostrSignerSessionLookup::Connection(connection) => *connection, - RadrootsNostrSignerSessionLookup::None => { - return Ok(MycNip46HandledOutcome::respond(Response::Error { - result: None, - error: "unauthorized".to_owned(), - })); - } - RadrootsNostrSignerSessionLookup::Ambiguous(_) => { - return Ok(MycNip46HandledOutcome::respond(Response::Error { - result: None, - error: "ambiguous client sessions".to_owned(), - })); - } - }; - if connection.status != RadrootsNostrSignerConnectionStatus::Active { - let reason = format!("connection is {:?}", connection.status).to_lowercase(); - let audit = manager.record_request( - &connection.connection_id, - &request_message.id, - request_message.request.method(), - RadrootsNostrSignerRequestDecision::Denied, - Some(reason.clone()), - )?; - return Ok(MycNip46HandledOutcome::new( - RadrootsNostrSignerHandledRequest::respond_for_connection( - Some(connection.connection_id), - Response::Error { - result: None, - error: reason, - }, - ), - Some(audit), - )); - } - - let audit = manager.record_request( - &connection.connection_id, - &request_message.id, - request_message.request.method(), - RadrootsNostrSignerRequestDecision::Allowed, - None, - )?; - Ok(MycNip46HandledOutcome::new( - RadrootsNostrSignerHandledRequest::respond_for_connection( - Some(connection.connection_id), - Response::LogoutAcknowledged, - ), - Some(audit), - )) - } - - #[cfg(test)] - fn handle_request_response( - &self, - client_public_key: RadrootsNostrPublicKey, - request_message: RequestMessage, - ) -> Result<Response, MycError> { - match self.handle_request(client_public_key, request_message)? { - MycNip46HandledOutcome { - handled_request: RadrootsNostrSignerHandledRequest::Respond { response, .. }, - .. - } => Ok(*response), - MycNip46HandledOutcome { - handled_request: RadrootsNostrSignerHandledRequest::Ignore, - .. - } => Err(MycError::InvalidOperation( - "request was ignored without a response".to_owned(), - )), - } - } - - pub(crate) fn handle_authorized_request_evaluation( - &self, - request_message: RequestMessage, - evaluation: RadrootsNostrSignerRequestEvaluation, - ) -> Result<MycNip46HandledOutcome, MycError> { - self.handler - .handle_authorized_request_evaluation(request_message, evaluation) - .map_err(Into::into) - } -} - -impl MycNip46Service { - pub fn new( - signer: MycSignerContext, - transport: MycNostrTransport, - delivery_outbox_store: Arc<dyn MycDeliveryOutboxStore>, - ) -> Self { - let handler = MycNip46Handler::new(signer, transport.relays().to_vec()); - Self { - handler, - transport, - delivery_outbox_store, - } - } - - pub async fn run(&self) -> Result<(), MycError> { - self.run_until(std::future::pending()).await - } - - pub async fn run_until<F>(&self, shutdown: F) -> Result<(), MycError> - where - F: Future<Output = ()>, - { - tokio::pin!(shutdown); - self.transport.connect().await?; - - let filter = self.handler.filter()?; - let sdk_client = self.transport.client().clone().into_inner(); - let mut notifications = sdk_client.notifications(); - let subscription = self.transport.client().subscribe(filter, None).await?; - let mut replay_guard = MycNip46ReplayGuard::new(NIP46_REPLAY_CACHE_CAPACITY); - tracing::info!( - subscription_id = %subscription.val, - relay_count = self.transport.relays().len(), - "myc NIP-46 listener subscribed" - ); - - loop { - let notification = tokio::select! { - _ = &mut shutdown => return Ok(()), - notification = notifications.recv() => { - match notification { - Ok(notification) => notification, - Err(broadcast::error::RecvError::Lagged(_)) => continue, - Err(broadcast::error::RecvError::Closed) => { - return Err(MycError::Nip46ListenerClosed); - } - } - } - }; - let RadrootsNostrRelayPoolNotification::Event { event, .. } = notification else { - continue; - }; - let event = *event; - if event.kind != RadrootsNostrKind::Custom(RPC_KIND) { - continue; - } - - let request_message = match self.handler.parse_request_event(&event) { - Ok(message) => message, - Err(error) => { - tracing::warn!(error = %error, "discarding invalid NIP-46 request event"); - continue; - } - }; - if !replay_guard.accept(event.id.to_hex()) { - tracing::warn!(event_id = %event.id, "discarding replayed NIP-46 request event"); - continue; - } - - let request_id = request_message.id.clone(); - let handled_outcome = match self.handler.handle_request(event.pubkey, request_message) { - Ok(handled_outcome) => handled_outcome, - Err(error) => { - tracing::warn!(error = %error, "failed to handle NIP-46 request"); - MycNip46HandledOutcome::respond(Response::Error { - result: None, - error: error.to_string(), - }) - } - }; - if let Some(audit) = handled_outcome.audit.as_ref() { - self.handler.signer.record_signer_request_audit(audit); - } - let Some((response, connection_id, consume_connect_secret_for)) = - handled_outcome.handled_request.into_publish_parts() - else { - tracing::debug!( - request_id = %request_id, - client_public_key = %event.pubkey, - "ignoring NIP-46 request without response" - ); - continue; - }; - let revoke_logout_connection = matches!(&response, Response::LogoutAcknowledged) - .then(|| connection_id.clone()) - .flatten(); - - let response_event = - self.handler - .build_response_event(event.pubkey, request_id.as_str(), response)?; - let response_event = match self - .handler - .signer - .signer_identity() - .sign_protocol_event_builder(response_event, "NIP-46 response") - { - Ok(event) => event, - Err(error) => { - self.record_listener_publish_local_rejection( - connection_id.as_ref(), - request_id.as_str(), - format!("failed to sign NIP-46 response event: {error}"), - ); - continue; - } - }; - - let mut workflow_id = None; - if let Some(connect_connection_id) = consume_connect_secret_for.as_ref() { - let manager = match self.handler.signer.load_signer_manager() { - Ok(manager) => manager, - Err(error) => { - self.record_listener_publish_local_rejection( - connection_id.as_ref(), - request_id.as_str(), - error.to_string(), - ); - continue; - } - }; - match manager.begin_connect_secret_publish_finalization(connect_connection_id) { - Ok(workflow) => workflow_id = Some(workflow.workflow_id), - Err(error) => { - self.record_listener_publish_local_rejection( - connection_id.as_ref(), - request_id.as_str(), - format!( - "failed to begin connect-secret publish finalization workflow: {error}" - ), - ); - continue; - } - } - } - - let outbox_record = match self.build_listener_outbox_record( - if revoke_logout_connection.is_some() { - MycDeliveryOutboxKind::LogoutAcknowledgementPublish - } else { - MycDeliveryOutboxKind::ListenerResponsePublish - }, - response_event.clone(), - connection_id.as_ref(), - request_id.as_str(), - workflow_id.as_ref(), - ) { - Ok(record) => record, - Err(error) => { - let error = self - .cancel_listener_publish_workflow_if_needed(workflow_id.as_ref(), error); - self.record_listener_publish_local_rejection( - connection_id.as_ref(), - request_id.as_str(), - error.to_string(), - ); - continue; - } - }; - if let Err(error) = self.delivery_outbox_store.enqueue(&outbox_record) { - let error = - self.cancel_listener_publish_workflow_if_needed(workflow_id.as_ref(), error); - self.record_listener_publish_local_rejection( - connection_id.as_ref(), - request_id.as_str(), - error.to_string(), - ); - continue; - } - let publish_outcome = match self - .transport - .publish_event("NIP-46 response publish", &response_event) - .await - { - Ok(publish_outcome) => publish_outcome, - Err(error) => { - let mut error = self.record_listener_outbox_failure(&outbox_record, error); - error = self - .cancel_listener_publish_workflow_if_needed(workflow_id.as_ref(), error); - self.record_listener_publish_error( - connection_id.as_ref(), - request_id.as_str(), - &error, - ); - continue; - } - }; - if let Some(workflow_id) = workflow_id.as_ref() { - let manager = match self.handler.signer.load_signer_manager() { - Ok(manager) => manager, - Err(error) => { - self.record_listener_publish_post_publish_failure( - connection_id.as_ref(), - request_id.as_str(), - &publish_outcome, - format!( - "failed to load signer manager for publish finalization: {error}" - ), - ); - continue; - } - }; - if let Err(error) = manager.mark_publish_workflow_published(workflow_id) { - self.record_listener_publish_post_publish_failure( - connection_id.as_ref(), - request_id.as_str(), - &publish_outcome, - format!("failed to mark signer publish workflow as published: {error}"), - ); - continue; - } - } - if let Err(error) = self.delivery_outbox_store.mark_published_pending_finalize( - &outbox_record.job_id, - publish_outcome.attempt_count, - ) { - self.record_listener_publish_post_publish_failure( - connection_id.as_ref(), - request_id.as_str(), - &publish_outcome, - format!("failed to persist delivery outbox published state: {error}"), - ); - continue; - } - if let Some(workflow_id) = workflow_id.as_ref() { - let manager = match self.handler.signer.load_signer_manager() { - Ok(manager) => manager, - Err(error) => { - self.record_listener_publish_post_publish_failure( - connection_id.as_ref(), - request_id.as_str(), - &publish_outcome, - format!("failed to load signer manager for publish workflow finalization: {error}"), - ); - continue; - } - }; - if let Err(error) = manager.finalize_publish_workflow(workflow_id) { - self.record_listener_publish_post_publish_failure( - connection_id.as_ref(), - request_id.as_str(), - &publish_outcome, - format!("failed to finalize signer publish workflow: {error}"), - ); - continue; - } - } - if let Some(logout_connection_id) = revoke_logout_connection.as_ref() { - let manager = match self.handler.signer.load_signer_manager() { - Ok(manager) => manager, - Err(error) => { - self.record_listener_publish_post_publish_failure( - connection_id.as_ref(), - request_id.as_str(), - &publish_outcome, - format!( - "failed to load signer manager for logout finalization: {error}" - ), - ); - continue; - } - }; - if let Err(error) = manager.revoke_connection( - logout_connection_id, - Some("NIP-46 logout acknowledged".to_owned()), - ) { - self.record_listener_publish_post_publish_failure( - connection_id.as_ref(), - request_id.as_str(), - &publish_outcome, - format!("failed to finalize NIP-46 logout: {error}"), - ); - continue; - } - } - if let Err(error) = self - .delivery_outbox_store - .mark_finalized(&outbox_record.job_id) - { - self.record_listener_publish_post_publish_failure( - connection_id.as_ref(), - request_id.as_str(), - &publish_outcome, - format!("failed to finalize delivery outbox job: {error}"), - ); - continue; - } - self.record_listener_publish_success( - connection_id.as_ref(), - request_id.as_str(), - &publish_outcome, - ); - } - } - - fn build_listener_outbox_record( - &self, - kind: MycDeliveryOutboxKind, - response_event: RadrootsNostrEvent, - connection_id: Option<&RadrootsNostrSignerConnectionId>, - request_id: &str, - workflow_id: Option<&RadrootsNostrSignerWorkflowId>, - ) -> Result<MycDeliveryOutboxRecord, MycError> { - let mut record = - MycDeliveryOutboxRecord::new(kind, response_event, self.transport.relays().to_vec())? - .with_request_id(request_id.to_owned()); - if let Some(connection_id) = connection_id { - record = record.with_connection_id(connection_id); - } - if let Some(workflow_id) = workflow_id { - record = record.with_signer_publish_workflow_id(workflow_id); - } - Ok(record) - } - - fn cancel_listener_publish_workflow_if_needed( - &self, - workflow_id: Option<&RadrootsNostrSignerWorkflowId>, - error: MycError, - ) -> MycError { - let Some(workflow_id) = workflow_id else { - return error; - }; - match self - .handler - .signer - .load_signer_manager() - .and_then(|manager| { - manager - .cancel_publish_workflow(workflow_id) - .map(|_| ()) - .map_err(Into::into) - }) { - Ok(()) => error, - Err(cancel_error) => MycError::InvalidOperation(format!( - "{error}; additionally failed to cancel listener publish workflow: {cancel_error}" - )), - } - } - - fn record_listener_outbox_failure( - &self, - outbox_record: &MycDeliveryOutboxRecord, - error: MycError, - ) -> MycError { - let publish_attempt_count = error.publish_attempt_count().unwrap_or_default(); - let failure_summary = error - .publish_rejection_details() - .map(ToOwned::to_owned) - .unwrap_or_else(|| error.to_string()); - match self.delivery_outbox_store.mark_failed( - &outbox_record.job_id, - publish_attempt_count, - &failure_summary, - ) { - Ok(_) => error, - Err(outbox_error) => MycError::InvalidOperation(format!( - "{error}; additionally failed to persist listener publish failure to the outbox: {outbox_error}" - )), - } - } - - fn record_listener_publish_local_rejection( - &self, - connection_id: Option<&RadrootsNostrSignerConnectionId>, - request_id: &str, - summary: impl Into<String>, - ) { - self.handler - .signer - .record_operation_audit(&MycOperationAuditRecord::new( - MycOperationAuditKind::ListenerResponsePublish, - MycOperationAuditOutcome::Rejected, - connection_id, - Some(request_id), - self.transport.relays().len(), - 0, - summary.into(), - )); - } - - fn record_listener_publish_error( - &self, - connection_id: Option<&RadrootsNostrSignerConnectionId>, - request_id: &str, - error: &MycError, - ) { - let mut record = MycOperationAuditRecord::new( - MycOperationAuditKind::ListenerResponsePublish, - MycOperationAuditOutcome::Rejected, - connection_id, - Some(request_id), - error - .publish_rejection_counts() - .map(|(relay_count, _)| relay_count) - .unwrap_or(self.transport.relays().len()), - error - .publish_rejection_counts() - .map(|(_, acknowledged)| acknowledged) - .unwrap_or_default(), - error - .publish_rejection_details() - .map(ToOwned::to_owned) - .unwrap_or_else(|| error.to_string()), - ); - if let ( - Some(delivery_policy), - Some(required_acknowledged_relay_count), - Some(attempt_count), - ) = ( - error.publish_delivery_policy(), - error.publish_required_acknowledged_relay_count(), - error.publish_attempt_count(), - ) { - record = record.with_delivery_details( - delivery_policy, - required_acknowledged_relay_count, - attempt_count, - ); - } - self.handler.signer.record_operation_audit(&record); - } - - fn record_listener_publish_post_publish_failure( - &self, - connection_id: Option<&RadrootsNostrSignerConnectionId>, - request_id: &str, - publish_outcome: &crate::transport::MycPublishOutcome, - summary: impl Into<String>, - ) { - self.handler.signer.record_operation_audit( - &MycOperationAuditRecord::new( - MycOperationAuditKind::ListenerResponsePublish, - MycOperationAuditOutcome::Rejected, - connection_id, - Some(request_id), - publish_outcome.relay_count, - publish_outcome.acknowledged_relay_count, - summary.into(), - ) - .with_delivery_details( - publish_outcome.delivery_policy, - publish_outcome.required_acknowledged_relay_count, - publish_outcome.attempt_count, - ), - ); - } - - fn record_listener_publish_success( - &self, - connection_id: Option<&RadrootsNostrSignerConnectionId>, - request_id: &str, - publish_outcome: &crate::transport::MycPublishOutcome, - ) { - self.handler.signer.record_operation_audit( - &MycOperationAuditRecord::new( - MycOperationAuditKind::ListenerResponsePublish, - MycOperationAuditOutcome::Succeeded, - connection_id, - Some(request_id), - publish_outcome.relay_count, - publish_outcome.acknowledged_relay_count, - publish_outcome.relay_outcome_summary.clone(), - ) - .with_delivery_details( - publish_outcome.delivery_policy, - publish_outcome.required_acknowledged_relay_count, - publish_outcome.attempt_count, - ), - ); - } -} - -#[cfg(test)] -mod tests { - use crate::nostr_contract::{RadrootsNostrTag, radroots_nostr_kind}; - use crate::signer::prelude::{ - RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerConnectionStatus, - RadrootsNostrSignerHandledRequest, - }; - use nostr::nips::nip04; - use nostr::nips::nip44; - use nostr::nips::nip44::Version; - use nostr::{EventBuilder, Keys, PublicKey, SecretKey, Timestamp}; - use radroots_nostr_connect::message::UnsignedEvent; - use radroots_nostr_connect::{ - Method, Permission, Request, Response, - message::{RPC_KIND, RequestMessage, ResponseEnvelope}, - }; - use serde_json::json; - - use crate::app::MycRuntime; - use crate::config::{MycConfig, MycConnectionApproval}; - - use super::MycNip46Handler; - - fn write_identity(path: &std::path::Path, secret_key: &str) { - let identity = crate::host_identity::RadrootsIdentity::from_secret_key_str(secret_key) - .expect("identity"); - crate::identity_files::store_encrypted_identity(path, &identity).expect("save identity"); - } - - fn runtime() -> MycRuntime { - runtime_with_config(MycConnectionApproval::NotRequired, |_| {}) - } - - fn runtime_with_config<F>(approval: MycConnectionApproval, configure: F) -> MycRuntime - where - F: FnOnce(&mut MycConfig), - { - let temp = tempfile::tempdir().expect("tempdir").keep(); - let mut config = crate::config::test_config(&temp); - config.paths.signer_identity_path = temp.join("signer.json"); - config.paths.user_identity_path = temp.join("user.json"); - config.policy.connection_approval = approval; - config.transport.enabled = true; - config.transport.connect_timeout_secs = 15; - config.transport.relays = vec!["wss://relay.example.com".to_owned()]; - configure(&mut config); - write_identity( - &config.paths.signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_identity( - &config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - MycRuntime::bootstrap(config).expect("runtime") - } - - fn runtime_with_explicit_approval() -> MycRuntime { - runtime_with_config(MycConnectionApproval::ExplicitUser, |_| {}) - } - - fn handler(runtime: &MycRuntime) -> MycNip46Handler { - MycNip46Handler::new( - runtime.signer_context(), - runtime.transport().expect("transport").relays().to_vec(), - ) - } - - fn client_keys() -> Keys { - client_keys_from_hex("3333333333333333333333333333333333333333333333333333333333333333") - } - - fn client_keys_from_hex(secret_key: &str) -> Keys { - let secret = SecretKey::from_hex(secret_key).expect("secret"); - Keys::new(secret) - } - - fn request_event(handler: &MycNip46Handler, request: RequestMessage) -> nostr::Event { - request_event_with_client_keys(handler, request, &client_keys()) - } - - fn request_event_with_client_keys( - handler: &MycNip46Handler, - request: RequestMessage, - client_keys: &Keys, - ) -> nostr::Event { - let payload = serde_json::to_string(&request).expect("serialize request"); - let ciphertext = nip44::encrypt( - client_keys.secret_key(), - &PublicKey::parse( - handler - .signer - .signer_public_identity() - .public_key_hex - .as_str(), - ) - .expect("signer pubkey"), - payload, - Version::V2, - ) - .expect("encrypt"); - EventBuilder::new(radroots_nostr_kind(RPC_KIND), ciphertext) - .tags(vec![RadrootsNostrTag::public_key( - handler.signer.signer_identity().public_key(), - )]) - .sign_with_keys(client_keys) - .expect("sign request") - } - - fn sign_event_permission(kind: u16) -> Permission { - Permission::with_parameter(Method::SignEvent, format!("kind:{kind}")) - } - - fn unsigned_event(pubkey: PublicKey, kind: u16, content: &str) -> UnsignedEvent { - UnsignedEvent::from_json( - &json!({ - "pubkey": pubkey.to_hex(), - "created_at": Timestamp::from(1).as_secs(), - "kind": kind, - "tags": [], - "content": content - }) - .to_string(), - ) - .expect("unsigned event") - } - - fn connect_with_permissions( - handler: &MycNip46Handler, - runtime: &MycRuntime, - requested_permissions: Vec<Permission>, - ) { - handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new( - "req-connect", - Request::Connect { - remote_signer_public_key: runtime - .signer_identity() - .public_identity() - .public_key(), - secret: None, - requested_permissions: requested_permissions.into(), - client_metadata: None, - }, - ), - ) - .expect("connect"); - } - - fn connection_for( - runtime: &MycRuntime, - client_public_key: PublicKey, - ) -> RadrootsNostrSignerConnectionRecord { - runtime - .signer_manager() - .expect("manager") - .find_connections_by_client_public_key(&client_public_key) - .expect("connections") - .into_iter() - .next() - .expect("connection") - } - - #[test] - fn parse_and_build_nip46_envelopes_roundtrip() { - let runtime = runtime(); - let handler = handler(&runtime); - let request = RequestMessage::new("req-1", Request::Ping); - let event = request_event(&handler, request.clone()); - - let parsed = handler.parse_request_event(&event).expect("parse request"); - assert_eq!(parsed, request); - - let response_builder = handler - .build_response_event(event.pubkey, "req-1", Response::Pong) - .expect("response builder"); - let response_event = runtime - .signer_identity() - .sign_protocol_event_builder(response_builder, "test response") - .expect("sign response"); - let decrypted = nip44::decrypt( - client_keys().secret_key(), - &runtime.signer_identity().public_key(), - &response_event.content, - ) - .expect("decrypt response"); - let envelope: ResponseEnvelope = serde_json::from_str(&decrypted).expect("parse envelope"); - let parsed = - Response::from_envelope(&Request::Ping.method(), envelope).expect("parse response"); - assert_eq!(parsed, Response::Pong); - } - - #[test] - fn replay_guard_rejects_duplicates_and_bounds_retention() { - let mut guard = super::MycNip46ReplayGuard::new(2); - assert!(guard.accept("event-1".to_owned())); - assert!(!guard.accept("event-1".to_owned())); - assert!(guard.accept("event-2".to_owned())); - assert!(guard.accept("event-3".to_owned())); - assert!(guard.accept("event-1".to_owned())); - } - - #[test] - fn connect_registers_client_and_echoes_secret() { - let runtime = runtime(); - let handler = handler(&runtime); - let response = handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new( - "req-connect", - Request::Connect { - remote_signer_public_key: runtime - .signer_identity() - .public_identity() - .public_key(), - secret: Some("s3cr3t".to_owned()), - requested_permissions: Default::default(), - client_metadata: None, - }, - ), - ) - .expect("connect response"); - - assert_eq!(response, Response::ConnectSecretEcho("s3cr3t".to_owned())); - let connections = runtime - .signer_manager() - .expect("manager") - .list_connections() - .expect("connections"); - assert_eq!(connections.len(), 1); - assert_eq!( - connections[0].user_identity.id.to_string(), - runtime.user_public_identity().id.to_string() - ); - assert_eq!(connections[0].relays.len(), 1); - } - - #[test] - fn denied_clients_are_rejected_without_registration() { - let denied_client_keys = client_keys_from_hex( - "4444444444444444444444444444444444444444444444444444444444444444", - ); - let runtime = runtime_with_config(MycConnectionApproval::ExplicitUser, |config| { - config.policy.denied_client_pubkeys = vec![denied_client_keys.public_key().to_hex()]; - }); - let handler = handler(&runtime); - - let response = handler - .handle_request_response( - denied_client_keys.public_key(), - RequestMessage::new( - "req-connect", - Request::Connect { - remote_signer_public_key: runtime - .signer_identity() - .public_identity() - .public_key(), - secret: None, - requested_permissions: Default::default(), - client_metadata: None, - }, - ), - ) - .expect("connect response"); - - assert_eq!( - response, - Response::Error { - result: None, - error: "client public key denied by policy".to_owned(), - } - ); - assert!( - runtime - .signer_manager() - .expect("manager") - .list_connections() - .expect("connections") - .is_empty() - ); - } - - #[test] - fn existing_unconsumed_connect_secret_can_still_retry_after_failed_publish() { - let runtime = runtime(); - let handler = handler(&runtime); - - let first = handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new( - "req-connect-1", - Request::Connect { - remote_signer_public_key: runtime - .signer_identity() - .public_identity() - .public_key(), - secret: Some("s3cr3t".to_owned()), - requested_permissions: Default::default(), - client_metadata: None, - }, - ), - ) - .expect("first connect response"); - let second = handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new( - "req-connect-2", - Request::Connect { - remote_signer_public_key: runtime - .signer_identity() - .public_identity() - .public_key(), - secret: Some("s3cr3t".to_owned()), - requested_permissions: Default::default(), - client_metadata: None, - }, - ), - ) - .expect("second connect response"); - - assert_eq!(first, Response::ConnectSecretEcho("s3cr3t".to_owned())); - assert_eq!(second, first); - } - - #[test] - fn consumed_connect_secret_is_ignored_on_reuse() { - let runtime = runtime(); - let handler = handler(&runtime); - let response = handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new( - "req-connect", - Request::Connect { - remote_signer_public_key: runtime - .signer_identity() - .public_identity() - .public_key(), - secret: Some("s3cr3t".to_owned()), - requested_permissions: Default::default(), - client_metadata: None, - }, - ), - ) - .expect("connect response"); - assert_eq!(response, Response::ConnectSecretEcho("s3cr3t".to_owned())); - - let connection = runtime - .signer_manager() - .expect("manager") - .list_connections() - .expect("connections") - .into_iter() - .next() - .expect("connection"); - runtime - .signer_manager() - .expect("manager") - .mark_connect_secret_consumed(&connection.connection_id) - .expect("consume connect secret"); - - let ignored = handler - .handle_request( - client_keys().public_key(), - RequestMessage::new( - "req-connect-reused", - Request::Connect { - remote_signer_public_key: runtime - .signer_identity() - .public_identity() - .public_key(), - secret: Some("s3cr3t".to_owned()), - requested_permissions: Default::default(), - client_metadata: None, - }, - ), - ) - .expect("ignored response"); - - assert_eq!( - ignored.handled_request, - RadrootsNostrSignerHandledRequest::Ignore - ); - let connections = runtime - .signer_manager() - .expect("manager") - .list_connections() - .expect("connections"); - assert_eq!(connections.len(), 1); - assert!(connections[0].connect_secret_is_consumed()); - } - - #[test] - fn connect_requests_are_throttled_after_configured_limit() { - let runtime = runtime_with_config(MycConnectionApproval::NotRequired, |config| { - config.policy.connect_rate_limit_window_secs = Some(1); - config.policy.connect_rate_limit_max_attempts = Some(1); - }); - let handler = handler(&runtime); - - let first = handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new( - "req-connect-1", - Request::Connect { - remote_signer_public_key: runtime - .signer_identity() - .public_identity() - .public_key(), - secret: None, - requested_permissions: Default::default(), - client_metadata: None, - }, - ), - ) - .expect("first connect response"); - assert_eq!(first, Response::ConnectAcknowledged); - - let second = handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new( - "req-connect-2", - Request::Connect { - remote_signer_public_key: runtime - .signer_identity() - .public_identity() - .public_key(), - secret: None, - requested_permissions: Default::default(), - client_metadata: None, - }, - ), - ) - .expect("second connect response"); - assert!(matches!( - second, - Response::Error { error, .. } - if error.contains("connect attempts throttled by policy") - )); - - let connection = connection_for(&runtime, client_keys().public_key()); - runtime - .signer_manager() - .expect("manager") - .revoke_connection(&connection.connection_id, Some("test reset".to_owned())) - .expect("revoke connection"); - - std::thread::sleep(std::time::Duration::from_secs(2)); - - let third = handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new( - "req-connect-3", - Request::Connect { - remote_signer_public_key: runtime - .signer_identity() - .public_identity() - .public_key(), - secret: None, - requested_permissions: Default::default(), - client_metadata: None, - }, - ), - ) - .expect("third connect response"); - assert_eq!(third, Response::ConnectAcknowledged); - } - - #[test] - fn connect_preserves_pending_status_when_explicit_approval_is_required() { - let runtime = runtime_with_explicit_approval(); - let handler = handler(&runtime); - - let response = handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new( - "req-connect", - Request::Connect { - remote_signer_public_key: runtime - .signer_identity() - .public_identity() - .public_key(), - secret: None, - requested_permissions: vec![sign_event_permission(1)].into(), - client_metadata: None, - }, - ), - ) - .expect("connect response"); - - assert_eq!(response, Response::ConnectAcknowledged); - let connection = runtime - .signer_manager() - .expect("manager") - .list_connections() - .expect("connections") - .into_iter() - .next() - .expect("connection"); - assert_eq!( - connection.status, - crate::signer::prelude::RadrootsNostrSignerConnectionStatus::Pending - ); - assert_eq!( - connection.approval_state, - crate::signer::prelude::RadrootsNostrSignerApprovalState::Pending - ); - assert!(connection.granted_permissions().as_slice().is_empty()); - } - - #[test] - fn logout_requires_active_session_and_defers_revocation_until_publish() { - let pending_runtime = runtime_with_explicit_approval(); - let pending_handler = handler(&pending_runtime); - connect_with_permissions(&pending_handler, &pending_runtime, Vec::new()); - let pending_response = pending_handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new("req-pending-logout", Request::Logout), - ) - .expect("pending logout response"); - assert_eq!( - pending_response, - Response::Error { - result: None, - error: "connection is pending".to_owned(), - } - ); - - let active_runtime = runtime(); - let active_handler = handler(&active_runtime); - connect_with_permissions(&active_handler, &active_runtime, Vec::new()); - let active_response = active_handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new("req-active-logout", Request::Logout), - ) - .expect("active logout response"); - assert_eq!(active_response, Response::LogoutAcknowledged); - assert_eq!( - connection_for(&active_runtime, client_keys().public_key()).status, - RadrootsNostrSignerConnectionStatus::Active - ); - } - - #[test] - fn trusted_clients_auto_grant_only_policy_allowed_permissions() { - let trusted_client_keys = client_keys_from_hex( - "4545454545454545454545454545454545454545454545454545454545454545", - ); - let runtime = runtime_with_config(MycConnectionApproval::ExplicitUser, |config| { - config.policy.trusted_client_pubkeys = vec![trusted_client_keys.public_key().to_hex()]; - config.policy.permission_ceiling = vec![ - Permission::new(Method::Nip04Encrypt), - sign_event_permission(1), - ] - .into(); - config.policy.allowed_sign_event_kinds = vec![1]; - }); - let handler = handler(&runtime); - - let response = handler - .handle_request_response( - trusted_client_keys.public_key(), - RequestMessage::new( - "req-connect", - Request::Connect { - remote_signer_public_key: runtime - .signer_identity() - .public_identity() - .public_key(), - secret: None, - requested_permissions: vec![ - Permission::new(Method::Nip04Encrypt), - Permission::new(Method::SignEvent), - sign_event_permission(7), - ] - .into(), - client_metadata: None, - }, - ), - ) - .expect("connect response"); - - assert_eq!(response, Response::ConnectAcknowledged); - let connection = connection_for(&runtime, trusted_client_keys.public_key()); - assert_eq!( - connection.granted_permissions().to_string(), - "nip04_encrypt,sign_event:kind:1" - ); - assert_eq!( - connection.requested_permissions.to_string(), - "nip04_encrypt,sign_event:kind:1" - ); - } - - #[test] - fn trusted_client_requires_auth_again_after_authorized_ttl() { - let trusted_client_keys = client_keys_from_hex( - "5656565656565656565656565656565656565656565656565656565656565656", - ); - let runtime = runtime_with_config(MycConnectionApproval::ExplicitUser, |config| { - config.policy.trusted_client_pubkeys = vec![trusted_client_keys.public_key().to_hex()]; - config.policy.permission_ceiling = vec![sign_event_permission(1)].into(); - config.policy.allowed_sign_event_kinds = vec![1]; - config.policy.auth_url = Some("https://auth.example/challenge".to_owned()); - config.policy.auth_authorized_ttl_secs = Some(1); - }); - let handler = handler(&runtime); - - let _ = handler - .handle_request_response( - trusted_client_keys.public_key(), - RequestMessage::new( - "req-connect", - Request::Connect { - remote_signer_public_key: runtime - .signer_identity() - .public_identity() - .public_key(), - secret: None, - requested_permissions: vec![sign_event_permission(1)].into(), - client_metadata: None, - }, - ), - ) - .expect("connect"); - - let first = handler - .handle_request_response( - trusted_client_keys.public_key(), - RequestMessage::new( - "req-sign-1", - Request::SignEvent(unsigned_event( - runtime.user_identity().public_key(), - 1, - "first", - )), - ), - ) - .expect("first sign request"); - assert_eq!( - first, - Response::AuthUrl("https://auth.example/challenge".to_owned()) - ); - - let connection = connection_for(&runtime, trusted_client_keys.public_key()); - runtime - .signer_manager() - .expect("manager") - .authorize_auth_challenge(&connection.connection_id) - .expect("authorize auth challenge"); - - let second = handler - .handle_request_response( - trusted_client_keys.public_key(), - RequestMessage::new( - "req-sign-2", - Request::SignEvent(unsigned_event( - runtime.user_identity().public_key(), - 1, - "second", - )), - ), - ) - .expect("second sign request"); - assert!(matches!(second, Response::SignedEvent(_))); - - std::thread::sleep(std::time::Duration::from_secs(2)); - - let third = handler - .handle_request_response( - trusted_client_keys.public_key(), - RequestMessage::new( - "req-sign-3", - Request::SignEvent(unsigned_event( - runtime.user_identity().public_key(), - 1, - "third", - )), - ), - ) - .expect("third sign request"); - assert_eq!( - third, - Response::AuthUrl("https://auth.example/challenge".to_owned()) - ); - } - - #[test] - fn trusted_client_requires_auth_again_after_inactivity() { - let trusted_client_keys = client_keys_from_hex( - "5757575757575757575757575757575757575757575757575757575757575757", - ); - let runtime = runtime_with_config(MycConnectionApproval::ExplicitUser, |config| { - config.policy.trusted_client_pubkeys = vec![trusted_client_keys.public_key().to_hex()]; - config.policy.permission_ceiling = vec![sign_event_permission(1)].into(); - config.policy.allowed_sign_event_kinds = vec![1]; - config.policy.auth_url = Some("https://auth.example/challenge".to_owned()); - config.policy.reauth_after_inactivity_secs = Some(1); - }); - let handler = handler(&runtime); - - let _ = handler - .handle_request_response( - trusted_client_keys.public_key(), - RequestMessage::new( - "req-connect", - Request::Connect { - remote_signer_public_key: runtime - .signer_identity() - .public_identity() - .public_key(), - secret: None, - requested_permissions: vec![sign_event_permission(1)].into(), - client_metadata: None, - }, - ), - ) - .expect("connect"); - - let first = handler - .handle_request_response( - trusted_client_keys.public_key(), - RequestMessage::new( - "req-sign-1", - Request::SignEvent(unsigned_event( - runtime.user_identity().public_key(), - 1, - "first", - )), - ), - ) - .expect("first sign request"); - assert_eq!( - first, - Response::AuthUrl("https://auth.example/challenge".to_owned()) - ); - - let connection = connection_for(&runtime, trusted_client_keys.public_key()); - runtime - .signer_manager() - .expect("manager") - .authorize_auth_challenge(&connection.connection_id) - .expect("authorize auth challenge"); - - std::thread::sleep(std::time::Duration::from_secs(2)); - - let second = handler - .handle_request_response( - trusted_client_keys.public_key(), - RequestMessage::new( - "req-sign-2", - Request::SignEvent(unsigned_event( - runtime.user_identity().public_key(), - 1, - "second", - )), - ), - ) - .expect("second sign request"); - assert_eq!( - second, - Response::AuthUrl("https://auth.example/challenge".to_owned()) - ); - } - - #[test] - fn trusted_client_auth_challenge_reissue_is_throttled() { - let trusted_client_keys = client_keys_from_hex( - "5858585858585858585858585858585858585858585858585858585858585858", - ); - let runtime = runtime_with_config(MycConnectionApproval::ExplicitUser, |config| { - config.policy.trusted_client_pubkeys = vec![trusted_client_keys.public_key().to_hex()]; - config.policy.permission_ceiling = vec![sign_event_permission(1)].into(); - config.policy.allowed_sign_event_kinds = vec![1]; - config.policy.auth_url = Some("https://auth.example/challenge".to_owned()); - config.policy.auth_pending_ttl_secs = 1; - config.policy.auth_challenge_rate_limit_window_secs = Some(60); - config.policy.auth_challenge_rate_limit_max_attempts = Some(1); - }); - let handler = handler(&runtime); - - let _ = handler - .handle_request_response( - trusted_client_keys.public_key(), - RequestMessage::new( - "req-connect", - Request::Connect { - remote_signer_public_key: runtime - .signer_identity() - .public_identity() - .public_key(), - secret: None, - requested_permissions: vec![sign_event_permission(1)].into(), - client_metadata: None, - }, - ), - ) - .expect("connect"); - - let first = handler - .handle_request_response( - trusted_client_keys.public_key(), - RequestMessage::new( - "req-sign-1", - Request::SignEvent(unsigned_event( - runtime.user_identity().public_key(), - 1, - "first", - )), - ), - ) - .expect("first sign request"); - assert_eq!( - first, - Response::AuthUrl("https://auth.example/challenge".to_owned()) - ); - - std::thread::sleep(std::time::Duration::from_secs(2)); - - let second = handler - .handle_request_response( - trusted_client_keys.public_key(), - RequestMessage::new( - "req-sign-2", - Request::SignEvent(unsigned_event( - runtime.user_identity().public_key(), - 1, - "second", - )), - ), - ) - .expect("second sign request"); - assert!(matches!( - second, - Response::Error { error, .. } - if error.contains("auth challenge issuance throttled by policy") - )); - } - - #[test] - fn base_methods_return_spec_results_after_connect() { - let runtime = runtime(); - let handler = handler(&runtime); - handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new( - "req-connect", - Request::Connect { - remote_signer_public_key: runtime - .signer_identity() - .public_identity() - .public_key(), - secret: None, - requested_permissions: vec![Permission::new(Method::SwitchRelays)].into(), - client_metadata: None, - }, - ), - ) - .expect("connect"); - - let public_key = handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new("req-pubkey", Request::GetPublicKey), - ) - .expect("get public key"); - assert_eq!( - public_key, - Response::UserPublicKey(runtime.user_identity().public_identity().public_key()) - ); - - let pong = handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new("req-ping", Request::Ping), - ) - .expect("ping"); - assert_eq!(pong, Response::Pong); - - let relays = handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new("req-switch", Request::SwitchRelays), - ) - .expect("switch relays"); - assert_eq!( - relays, - Response::RelayList( - runtime - .transport() - .expect("transport") - .relays() - .iter() - .map(|relay| { - radroots_nostr_connect::uri::RelayUrl::parse(relay.as_str()).expect("relay") - }) - .collect() - ) - ); - - let capability = handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new("req-capability", Request::GetSessionCapability), - ) - .expect("get session capability"); - assert_eq!( - capability, - Response::RemoteSessionCapability( - radroots_nostr_connect::message::RemoteSessionCapability { - user_public_key: runtime.user_identity().public_identity().public_key(), - relays: runtime - .transport() - .expect("transport") - .relays() - .iter() - .map(|relay| { - radroots_nostr_connect::uri::RelayUrl::parse(relay.as_str()) - .expect("relay") - }) - .collect(), - permissions: vec![Permission::new(Method::SwitchRelays,)].into(), - }, - ) - ); - } - - #[test] - fn new_connections_preserve_requested_permissions_without_expansion() { - let runtime = runtime(); - let handler = handler(&runtime); - handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new( - "req-connect", - Request::Connect { - remote_signer_public_key: runtime - .signer_identity() - .public_identity() - .public_key(), - secret: None, - requested_permissions: vec![sign_event_permission(1)].into(), - client_metadata: None, - }, - ), - ) - .expect("connect"); - - let connection = runtime - .signer_manager() - .expect("manager") - .list_connections() - .expect("connections") - .into_iter() - .next() - .expect("connection"); - assert_eq!( - connection.granted_permissions().as_slice(), - &[sign_event_permission(1)] - ); - } - - #[test] - fn sign_event_returns_signed_event_for_managed_user_key() { - let runtime = runtime(); - let handler = handler(&runtime); - connect_with_permissions(&handler, &runtime, vec![sign_event_permission(1)]); - - let response = handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new( - "req-sign", - Request::SignEvent(unsigned_event( - runtime.user_identity().public_key(), - 1, - "hello world", - )), - ), - ) - .expect("sign event"); - - let Response::SignedEvent(event) = response else { - panic!("unexpected sign_event response"); - }; - let event: nostr::Event = serde_json::from_str(&event.as_json()).expect("signed event"); - assert_eq!(event.pubkey, runtime.user_identity().public_key()); - assert_eq!(event.kind.as_u16(), 1); - assert_eq!(event.content, "hello world"); - assert!(event.verify_signature()); - } - - #[test] - fn sign_event_is_denied_without_permission() { - let runtime = runtime(); - let handler = handler(&runtime); - connect_with_permissions(&handler, &runtime, Vec::new()); - - let response = handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new( - "req-sign", - Request::SignEvent(unsigned_event( - runtime.user_identity().public_key(), - 1, - "hello world", - )), - ), - ) - .expect("sign event"); - - assert_eq!( - response, - Response::Error { - result: None, - error: "unauthorized sign_event".to_owned(), - } - ); - } - - #[test] - fn sign_event_rejects_pubkey_mismatch() { - let runtime = runtime(); - let handler = handler(&runtime); - connect_with_permissions(&handler, &runtime, vec![sign_event_permission(1)]); - - let response = handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new( - "req-sign", - Request::SignEvent(unsigned_event( - client_keys().public_key(), - 1, - "hello world", - )), - ), - ) - .expect("sign event"); - - assert_eq!( - response, - Response::Error { - result: None, - error: "sign_event pubkey does not match the managed user identity".to_owned(), - } - ); - } - - #[test] - fn nip04_encrypt_and_decrypt_roundtrip_on_managed_user_identity() { - let runtime = runtime(); - let handler = handler(&runtime); - connect_with_permissions( - &handler, - &runtime, - vec![ - Permission::new(Method::Nip04Encrypt), - Permission::new(Method::Nip04Decrypt), - ], - ); - - let encrypt_response = handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new( - "req-nip04-encrypt", - Request::Nip04Encrypt { - public_key: radroots_nostr::key::public_key_from_nostr( - client_keys().public_key(), - ) - .expect("identity public key"), - plaintext: "hello from myc".to_owned(), - }, - ), - ) - .expect("nip04 encrypt"); - let Response::Nip04Encrypt(ciphertext) = encrypt_response else { - panic!("unexpected nip04 encrypt response"); - }; - assert_eq!( - nip04::decrypt( - client_keys().secret_key(), - &runtime.user_identity().public_key(), - ciphertext.clone(), - ) - .expect("client decrypt"), - "hello from myc" - ); - - let client_ciphertext = nip04::encrypt( - client_keys().secret_key(), - &runtime.user_identity().public_key(), - "hello to myc", - ) - .expect("client encrypt"); - let decrypt_response = handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new( - "req-nip04-decrypt", - Request::Nip04Decrypt { - public_key: radroots_nostr::key::public_key_from_nostr( - client_keys().public_key(), - ) - .expect("identity public key"), - ciphertext: client_ciphertext, - }, - ), - ) - .expect("nip04 decrypt"); - assert_eq!( - decrypt_response, - Response::Nip04Decrypt("hello to myc".to_owned()) - ); - } - - #[test] - fn nip44_encrypt_and_decrypt_roundtrip_on_managed_user_identity() { - let runtime = runtime(); - let handler = handler(&runtime); - connect_with_permissions( - &handler, - &runtime, - vec![ - Permission::new(Method::Nip44Encrypt), - Permission::new(Method::Nip44Decrypt), - ], - ); - - let encrypt_response = handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new( - "req-nip44-encrypt", - Request::Nip44Encrypt { - public_key: radroots_nostr::key::public_key_from_nostr( - client_keys().public_key(), - ) - .expect("identity public key"), - plaintext: "hello from myc".to_owned(), - }, - ), - ) - .expect("nip44 encrypt"); - let Response::Nip44Encrypt(ciphertext) = encrypt_response else { - panic!("unexpected nip44 encrypt response"); - }; - assert_eq!( - nip44::decrypt( - client_keys().secret_key(), - &runtime.user_identity().public_key(), - ciphertext.clone(), - ) - .expect("client decrypt"), - "hello from myc" - ); - - let client_ciphertext = nip44::encrypt( - client_keys().secret_key(), - &runtime.user_identity().public_key(), - "hello to myc", - Version::V2, - ) - .expect("client encrypt"); - let decrypt_response = handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new( - "req-nip44-decrypt", - Request::Nip44Decrypt { - public_key: radroots_nostr::key::public_key_from_nostr( - client_keys().public_key(), - ) - .expect("identity public key"), - ciphertext: client_ciphertext, - }, - ), - ) - .expect("nip44 decrypt"); - assert_eq!( - decrypt_response, - Response::Nip44Decrypt("hello to myc".to_owned()) - ); - } - - #[test] - fn nip04_decrypt_is_denied_without_matching_permission() { - let runtime = runtime(); - let handler = handler(&runtime); - connect_with_permissions( - &handler, - &runtime, - vec![Permission::new(Method::Nip04Encrypt)], - ); - - let response = handler - .handle_request_response( - client_keys().public_key(), - RequestMessage::new( - "req-nip04-decrypt", - Request::Nip04Decrypt { - public_key: radroots_nostr::key::public_key_from_nostr( - client_keys().public_key(), - ) - .expect("identity public key"), - ciphertext: "invalid".to_owned(), - }, - ), - ) - .expect("nip04 decrypt"); - - assert_eq!( - response, - Response::Error { - result: None, - error: "unauthorized nip04_decrypt".to_owned(), - } - ); - } -} diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs @@ -1,5046 +0,0 @@ -use std::collections::{HashMap, VecDeque}; -use std::net::TcpListener as StdTcpListener; -use std::sync::Arc; -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use myc::control; -use myc::host_identity::RadrootsIdentity; -use myc::nostr_contract::{ - RadrootsNostrApplicationHandlerSpec, RadrootsNostrClient, RadrootsNostrGenericEventBuilder, - RadrootsNostrKind, RadrootsNostrMetadata, RadrootsNostrRelayUrl, RadrootsNostrTag, - radroots_nostr_build_application_handler_event, -}; -use myc::signer::prelude::{ - RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerAuthState, - RadrootsNostrSignerConnectionDraft, RadrootsNostrSignerConnectionStatus, -}; -use myc::{ - MycActiveIdentity, MycConfig, MycConnectionApproval, MycDeliveryOutboxKind, - MycDeliveryOutboxRecord, MycDeliveryOutboxStatus, MycDiscoveryContext, MycDiscoveryLiveStatus, - MycDiscoveryRelayFetchStatus, MycDiscoveryRepairOutcome, MycOperationAuditKind, - MycOperationAuditOutcome, MycOperationAuditRecord, MycRuntime, MycRuntimeAuditBackend, - MycSignerStateBackend, MycTransportDeliveryPolicy, diff_live_nip89, fetch_live_nip89, - publish_nip89_event, refresh_nip89, -}; -use nostr::filter::MatchEventOptions; -use nostr::nips::nip44; -use nostr::nips::nip44::Version; -use nostr::nips::nip46::{ - NostrConnectMessage as ExternalNostrConnectMessage, - NostrConnectMethod as ExternalNostrConnectMethod, - NostrConnectRequest as ExternalNostrConnectRequest, - NostrConnectResponse as ExternalNostrConnectResponse, ResponseResult as ExternalResponseResult, -}; -use nostr::{ - ClientMessage, Event, EventBuilder, Filter, JsonUtil, Keys, Kind, PublicKey, RelayMessage, - SecretKey, SubscriptionId, Tag, Timestamp, UnsignedEvent, -}; -use radroots_nostr_connect::{ - Client, Request, Response, - client::{ClientEvent, EventOutcome, Target}, - message::{RPC_KIND, RequestId, RequestMessage, ResponseEnvelope}, - permission::Permissions, - uri::{ClientMetadata, RelayUrl as ConnectRelayUrl, Uri}, -}; -use tempfile::TempDir; -use tokio::net::{TcpListener, TcpStream}; -use tokio::sync::{Mutex, Notify, mpsc, oneshot}; -use tokio::time::{Instant, sleep, timeout}; -use tokio_tungstenite::tungstenite::Message; - -mod support; - -type TestResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>; - -fn connect_public_key(public_key: PublicKey) -> radroots_identity::PublicKey { - radroots_nostr::key::public_key_from_nostr(public_key).expect("identity public key") -} - -fn connect_unsigned_event( - public_key: PublicKey, - created_at_unix: u64, - kind: u16, - content: &str, -) -> radroots_nostr_connect::message::UnsignedEvent { - radroots_nostr_connect::message::UnsignedEvent::from_json( - &serde_json::json!({ - "pubkey": public_key.to_hex(), - "created_at": created_at_unix, - "kind": kind, - "tags": [], - "content": content, - }) - .to_string(), - ) - .expect("unsigned event") -} - -fn connect_client_uri( - identity: &RadrootsIdentity, - relays: &[&str], - secret: &str, - metadata: &ClientMetadata, -) -> TestResult<String> { - let mut query = url::form_urlencoded::Serializer::new(String::new()); - for relay in relays { - query.append_pair("relay", relay); - } - query.append_pair("secret", secret); - if !metadata.requested_permissions().is_empty() { - query.append_pair("perms", &metadata.requested_permissions().to_string()); - } - if let Some(name) = metadata.name() { - query.append_pair("name", name); - } - if let Some(url) = metadata.url() { - query.append_pair("url", url); - } - if let Some(image) = metadata.image() { - query.append_pair("image", image); - } - let uri = format!( - "nostrconnect://{}?{}", - identity.final_public_key(), - query.finish() - ); - Ok(Uri::parse(&uri)?.to_string()) -} - -const RELAY_EVENT_TIMEOUT: Duration = Duration::from_secs(15); -const EXTERNAL_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30); -const RUNTIME_STATE_TIMEOUT: Duration = Duration::from_secs(15); -const POLL_INTERVAL: Duration = Duration::from_millis(25); - -#[derive(Clone)] -struct RelaySubscription { - connection_id: usize, - subscription_id: SubscriptionId, - filters: Vec<Filter>, -} - -#[derive(Default)] -struct RelayState { - next_connection_id: usize, - senders: HashMap<usize, mpsc::UnboundedSender<Message>>, - subscriptions: Vec<RelaySubscription>, - published_events: Vec<Event>, - publish_outcomes_by_pubkey: HashMap<String, VecDeque<bool>>, -} - -struct TestRelay { - url: String, - state: Arc<Mutex<RelayState>>, - notify: Arc<Notify>, - shutdown_tx: Option<oneshot::Sender<()>>, -} - -impl TestRelay { - async fn spawn() -> TestResult<Self> { - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - let url = format!("ws://{addr}"); - let state = Arc::new(Mutex::new(RelayState::default())); - let notify = Arc::new(Notify::new()); - let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); - let relay_state = Arc::clone(&state); - let relay_notify = Arc::clone(&notify); - - tokio::spawn(async move { - loop { - tokio::select! { - _ = &mut shutdown_rx => break, - accept = listener.accept() => { - let Ok((stream, _)) = accept else { - break; - }; - let state = Arc::clone(&relay_state); - let notify = Arc::clone(&relay_notify); - tokio::spawn(async move { - let _ = handle_relay_connection(stream, state, notify).await; - }); - } - } - } - }); - - Ok(Self { - url, - state, - notify, - shutdown_tx: Some(shutdown_tx), - }) - } - - fn url(&self) -> &str { - self.url.as_str() - } - - async fn queue_publish_outcomes(&self, public_key: PublicKey, outcomes: &[bool]) { - let mut state = self.state.lock().await; - state - .publish_outcomes_by_pubkey - .insert(public_key.to_hex(), outcomes.iter().copied().collect()); - } - - async fn wait_for_subscription_count(&self, expected: usize) -> TestResult<()> { - timeout(RELAY_EVENT_TIMEOUT, async { - loop { - if self.state.lock().await.subscriptions.len() >= expected { - return; - } - self.notify.notified().await; - } - }) - .await?; - Ok(()) - } - - async fn wait_for_published_events_by_author( - &self, - public_key: PublicKey, - expected: usize, - ) -> TestResult<Vec<Event>> { - timeout(RELAY_EVENT_TIMEOUT, async { - loop { - let events = self.published_events_by_author(public_key).await; - if events.len() >= expected { - return events; - } - self.notify.notified().await; - } - }) - .await - .map_err(Into::into) - } - - async fn published_events_by_author(&self, public_key: PublicKey) -> Vec<Event> { - self.state - .lock() - .await - .published_events - .iter() - .filter(|event| event.pubkey == public_key) - .cloned() - .collect() - } -} - -impl Drop for TestRelay { - fn drop(&mut self) { - if let Some(shutdown_tx) = self.shutdown_tx.take() { - let _ = shutdown_tx.send(()); - } - } -} - -struct HangingRelay { - url: String, - shutdown_tx: Option<oneshot::Sender<()>>, -} - -impl HangingRelay { - async fn spawn(hold_open_for: Duration) -> TestResult<Self> { - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - let url = format!("ws://{addr}"); - let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); - - tokio::spawn(async move { - loop { - tokio::select! { - _ = &mut shutdown_rx => break, - accept = listener.accept() => { - let Ok((stream, _)) = accept else { - break; - }; - tokio::spawn(async move { - sleep(hold_open_for).await; - drop(stream); - }); - } - } - } - }); - - Ok(Self { - url, - shutdown_tx: Some(shutdown_tx), - }) - } - - fn url(&self) -> &str { - self.url.as_str() - } -} - -impl Drop for HangingRelay { - fn drop(&mut self) { - if let Some(shutdown_tx) = self.shutdown_tx.take() { - let _ = shutdown_tx.send(()); - } - } -} - -async fn handle_relay_connection( - stream: TcpStream, - state: Arc<Mutex<RelayState>>, - notify: Arc<Notify>, -) -> TestResult<()> { - let websocket = tokio_tungstenite::accept_async(stream).await?; - let (mut writer, mut reader) = websocket.split(); - let (tx, mut rx) = mpsc::unbounded_channel::<Message>(); - let connection_id = { - let mut state = state.lock().await; - let connection_id = state.next_connection_id; - state.next_connection_id += 1; - state.senders.insert(connection_id, tx); - notify.notify_waiters(); - connection_id - }; - - let writer_task = tokio::spawn(async move { - while let Some(message) = rx.recv().await { - if writer.send(message).await.is_err() { - break; - } - } - }); - - while let Some(message) = reader.next().await { - let message = message?; - let Message::Text(text) = message else { - continue; - }; - let client_message = ClientMessage::from_json(text.as_str())?; - handle_client_message(connection_id, client_message, &state, &notify).await?; - } - - writer_task.abort(); - let mut state = state.lock().await; - state.senders.remove(&connection_id); - state - .subscriptions - .retain(|subscription| subscription.connection_id != connection_id); - notify.notify_waiters(); - Ok(()) -} - -async fn handle_client_message( - connection_id: usize, - client_message: ClientMessage<'_>, - state: &Arc<Mutex<RelayState>>, - notify: &Arc<Notify>, -) -> TestResult<()> { - match client_message { - ClientMessage::Req { - subscription_id, - filters, - } => { - let (sender, matching_events) = { - let mut state = state.lock().await; - let matching_events = state - .published_events - .iter() - .filter(|event| { - filters - .iter() - .any(|filter| filter.match_event(event, MatchEventOptions::new())) - }) - .cloned() - .collect::<Vec<_>>(); - state.subscriptions.push(RelaySubscription { - connection_id, - subscription_id: subscription_id.as_ref().clone(), - filters: filters - .into_iter() - .map(|filter| filter.into_owned()) - .collect(), - }); - notify.notify_waiters(); - (state.senders.get(&connection_id).cloned(), matching_events) - }; - if let Some(sender) = sender { - for event in matching_events { - let message = - RelayMessage::event(subscription_id.as_ref().clone(), event).as_json(); - let _ = sender.send(Message::Text(message.into())); - } - let eose = RelayMessage::eose(subscription_id.as_ref().clone()).as_json(); - let _ = sender.send(Message::Text(eose.into())); - } - } - ClientMessage::Close(subscription_id) => { - let mut state = state.lock().await; - state.subscriptions.retain(|subscription| { - subscription.connection_id != connection_id - || subscription.subscription_id != *subscription_id - }); - notify.notify_waiters(); - } - ClientMessage::Event(event) => { - let event = event.into_owned(); - let (ok_message, subscriber_messages) = - accept_published_event(connection_id, event, state, notify).await?; - if let Some((sender, message)) = ok_message { - let _ = sender.send(message); - } - for (sender, message) in subscriber_messages { - let _ = sender.send(message); - } - } - _ => {} - } - - Ok(()) -} - -async fn accept_published_event( - connection_id: usize, - event: Event, - state: &Arc<Mutex<RelayState>>, - notify: &Arc<Notify>, -) -> TestResult<( - Option<(mpsc::UnboundedSender<Message>, Message)>, - Vec<(mpsc::UnboundedSender<Message>, Message)>, -)> { - let event_id = event.id; - let event_pubkey_hex = event.pubkey.to_hex(); - let mut subscriber_messages = Vec::new(); - let mut ok_message = None; - - { - let mut state = state.lock().await; - let publish_status = state - .publish_outcomes_by_pubkey - .get_mut(&event_pubkey_hex) - .and_then(|outcomes| outcomes.pop_front()) - .unwrap_or(true); - - if let Some(sender) = state.senders.get(&connection_id).cloned() { - let message = if publish_status { - RelayMessage::ok(event_id, true, "").as_json() - } else { - RelayMessage::ok(event_id, false, "blocked by test relay").as_json() - }; - ok_message = Some((sender, Message::Text(message.into()))); - } - - if publish_status { - state.published_events.push(event.clone()); - for subscription in &state.subscriptions { - if subscription - .filters - .iter() - .any(|filter| filter.match_event(&event, MatchEventOptions::new())) - && let Some(sender) = state.senders.get(&subscription.connection_id).cloned() - { - let message = - RelayMessage::event(subscription.subscription_id.clone(), event.clone()) - .as_json(); - subscriber_messages.push((sender, Message::Text(message.into()))); - } - } - notify.notify_waiters(); - } - } - - Ok((ok_message, subscriber_messages)) -} - -struct MycTestRuntime { - _temp: TempDir, - runtime: MycRuntime, -} - -impl MycTestRuntime { - fn new(relay_url: &str, approval: MycConnectionApproval) -> Self { - Self::new_with_transport_relays(&[relay_url], approval) - } - - fn new_with_transport_relays(relay_urls: &[&str], approval: MycConnectionApproval) -> Self { - Self::new_with_transport_config(relay_urls, approval, |_| {}) - } - - fn new_with_transport_config<F>( - relay_urls: &[&str], - approval: MycConnectionApproval, - configure: F, - ) -> Self - where - F: FnOnce(&mut MycConfig), - { - let temp = tempfile::tempdir().expect("tempdir"); - let mut config = support::repo_local_config(temp.path()); - config.policy.connection_approval = approval; - config.transport.enabled = true; - config.transport.connect_timeout_secs = 1; - config.transport.relays = relay_urls.iter().map(|relay| (*relay).to_owned()).collect(); - let signer_identity_path = config.paths().signer_identity_path().to_path_buf(); - let user_identity_path = config.paths().user_identity_path().to_path_buf(); - configure(&mut config); - write_identity( - &signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_identity( - &user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - - Self { - runtime: MycRuntime::bootstrap(config).expect("runtime"), - _temp: temp, - } - } - - fn new_with_discovery(relay_url: &str, approval: MycConnectionApproval) -> Self { - Self::new_with_discovery_relays(&[relay_url], approval) - } - - fn new_with_discovery_relays(relay_urls: &[&str], approval: MycConnectionApproval) -> Self { - Self::new_with_discovery_relays_and_timeout(relay_urls, approval, 1) - } - - fn new_with_discovery_relays_and_timeout( - relay_urls: &[&str], - approval: MycConnectionApproval, - connect_timeout_secs: u64, - ) -> Self { - let temp = tempfile::tempdir().expect("tempdir"); - let mut config = support::repo_local_config(temp.path()); - config.policy.connection_approval = approval; - config.transport.connect_timeout_secs = connect_timeout_secs; - config.discovery.enabled = true; - config.discovery.domain = Some("signer.example.com".to_owned()); - config.discovery.public_relays = - relay_urls.iter().map(|relay| (*relay).to_owned()).collect(); - config.discovery.publish_relays = - relay_urls.iter().map(|relay| (*relay).to_owned()).collect(); - config.discovery.nostrconnect_url_template = - Some("https://signer.example.com/connect?uri=<nostrconnect>".to_owned()); - config.discovery.app_identity_path = Some(temp.path().join("app.json")); - let signer_identity_path = config.paths().signer_identity_path().to_path_buf(); - let user_identity_path = config.paths().user_identity_path().to_path_buf(); - write_identity( - &signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_identity( - &user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - write_identity( - config - .discovery - .app_identity_path - .as_ref() - .expect("app identity path"), - "6666666666666666666666666666666666666666666666666666666666666666", - ); - - Self { - runtime: MycRuntime::bootstrap(config).expect("runtime"), - _temp: temp, - } - } -} - -fn write_identity(path: &std::path::Path, secret_key: &str) { - let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); - myc::identity_files::store_encrypted_identity(path, &identity).expect("save identity"); -} - -fn identity(secret_key: &str) -> RadrootsIdentity { - RadrootsIdentity::from_secret_key_str(secret_key).expect("identity") -} - -fn unavailable_relay_url() -> TestResult<String> { - let listener = StdTcpListener::bind("127.0.0.1:0")?; - let addr = listener.local_addr()?; - drop(listener); - Ok(format!("ws://{addr}")) -} - -async fn publish_handler_event( - relay_url: &str, - identity: &RadrootsIdentity, - spec: &RadrootsNostrApplicationHandlerSpec, -) -> TestResult<Event> { - let event = radroots_nostr_build_application_handler_event(spec)? - .sign_with_keys(identity.keys()) - .map_err(|error| format!("failed to sign handler event: {error}"))?; - let client = RadrootsNostrClient::from_identity(identity); - let _ = client.add_relay(relay_url).await?; - client.connect().await; - client.wait_for_connection(Duration::from_secs(1)).await; - let output = client.send_event(&event).await?; - assert!( - !output.success.is_empty(), - "handler event publish did not succeed: {:?}", - output.failed - ); - Ok(event) -} - -async fn publish_signed_event( - relay_url: &str, - identity: &RadrootsIdentity, - event: &Event, -) -> TestResult<()> { - let client = RadrootsNostrClient::from_identity(identity); - let _ = client.add_relay(relay_url).await?; - client.connect().await; - client.wait_for_connection(Duration::from_secs(1)).await; - let output = client.send_event(event).await?; - assert!( - !output.success.is_empty(), - "signed event publish did not succeed: {:?}", - output.failed - ); - Ok(()) -} - -fn connect_request_message( - request_id: &str, - signer_public_key: PublicKey, - secret: &str, -) -> RequestMessage { - connect_request_message_with_metadata(request_id, signer_public_key, secret, None) -} - -fn connect_request_message_with_metadata( - request_id: &str, - signer_public_key: PublicKey, - secret: &str, - client_metadata: Option<ClientMetadata>, -) -> RequestMessage { - RequestMessage::new( - request_id, - Request::Connect { - remote_signer_public_key: connect_public_key(signer_public_key), - secret: Some(secret.to_owned()), - requested_permissions: Default::default(), - client_metadata, - }, - ) -} - -fn ping_request_message(request_id: &str) -> RequestMessage { - RequestMessage::new(request_id, Request::Ping) -} - -fn build_request_event( - client_identity: &RadrootsIdentity, - signer_public_key: PublicKey, - request_message: RequestMessage, - created_at_unix: u64, -) -> Event { - build_request_event_with_recipient( - client_identity, - signer_public_key, - signer_public_key, - request_message, - created_at_unix, - ) -} - -fn build_request_event_with_recipient( - client_identity: &RadrootsIdentity, - signer_public_key: PublicKey, - recipient_public_key: PublicKey, - request_message: RequestMessage, - created_at_unix: u64, -) -> Event { - let payload = serde_json::to_string(&request_message).expect("request payload"); - build_request_event_payload( - client_identity, - signer_public_key, - recipient_public_key, - payload.as_str(), - created_at_unix, - ) -} - -fn build_request_event_payload( - client_identity: &RadrootsIdentity, - signer_public_key: PublicKey, - recipient_public_key: PublicKey, - payload: &str, - created_at_unix: u64, -) -> Event { - let ciphertext = nip44::encrypt( - client_identity.keys().secret_key(), - &signer_public_key, - payload, - Version::V2, - ) - .expect("encrypt request"); - EventBuilder::new(Kind::Custom(RPC_KIND), ciphertext) - .tags([Tag::public_key(recipient_public_key)]) - .custom_created_at(Timestamp::from(created_at_unix)) - .sign_with_keys(client_identity.keys()) - .expect("sign request event") -} - -fn build_external_request_message( - request_id: &str, - request: &ExternalNostrConnectRequest, -) -> ExternalNostrConnectMessage { - ExternalNostrConnectMessage::Request { - id: request_id.to_owned(), - method: request.method(), - params: request.params(), - } -} - -fn build_external_request_event( - client_identity: &RadrootsIdentity, - signer_public_key: PublicKey, - request_message: &ExternalNostrConnectMessage, - created_at_unix: u64, -) -> Event { - let payload = request_message.as_json(); - let ciphertext = nip44::encrypt( - client_identity.keys().secret_key(), - &signer_public_key, - payload, - Version::V2, - ) - .expect("encrypt external request"); - EventBuilder::new(Kind::Custom(RPC_KIND), ciphertext) - .tags([Tag::public_key(signer_public_key)]) - .custom_created_at(Timestamp::from(created_at_unix)) - .sign_with_keys(client_identity.keys()) - .expect("sign external request event") -} - -fn build_signer_noise_event(signer_identity: &MycActiveIdentity, created_at_unix: u64) -> Event { - signer_identity - .sign_protocol_event_builder( - RadrootsNostrGenericEventBuilder::new( - RadrootsNostrKind::Custom(RPC_KIND), - "non-nip44-signer-noise", - ) - .custom_created_at(Timestamp::from(created_at_unix)), - "signer noise event", - ) - .expect("sign noise event") -} - -fn decrypt_response( - client_identity: &RadrootsIdentity, - signer_public_key: PublicKey, - response_event: &Event, -) -> ResponseEnvelope { - let plaintext = nip44::decrypt( - client_identity.keys().secret_key(), - &signer_public_key, - &response_event.content, - ) - .expect("decrypt response"); - serde_json::from_str(&plaintext).expect("response envelope") -} - -async fn wait_for_external_response( - relay: &TestRelay, - client_identity: &RadrootsIdentity, - signer_public_key: PublicKey, - request_id: &str, - method: ExternalNostrConnectMethod, -) -> TestResult<(Event, ExternalNostrConnectResponse)> { - timeout(EXTERNAL_RESPONSE_TIMEOUT, async { - loop { - let events = relay.published_events_by_author(signer_public_key).await; - for event in events { - let Ok(plaintext) = nip44::decrypt( - client_identity.keys().secret_key(), - &signer_public_key, - &event.content, - ) else { - continue; - }; - let Ok(message) = ExternalNostrConnectMessage::from_json(&plaintext) else { - continue; - }; - if message.id() != request_id { - continue; - } - let response = message.to_response(method)?; - return Ok((event, response)); - } - sleep(POLL_INTERVAL).await; - } - }) - .await - .map_err(|_| { - std::io::Error::new( - std::io::ErrorKind::TimedOut, - format!("timed out waiting for NIP-46 response `{request_id}`"), - ) - })? -} - -async fn publish_external_request_and_wait_for_response( - relay: &TestRelay, - client_identity: &RadrootsIdentity, - signer_public_key: PublicKey, - request_id: &str, - request: ExternalNostrConnectRequest, - created_at_unix: u64, -) -> TestResult<(Event, ExternalNostrConnectResponse)> { - let method = request.method(); - let request_message = build_external_request_message(request_id, &request); - let event = build_external_request_event( - client_identity, - signer_public_key, - &request_message, - created_at_unix, - ); - publish_event(relay.url(), &event).await?; - wait_for_external_response( - relay, - client_identity, - signer_public_key, - request_id, - method, - ) - .await -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn packaged_final_client_interoperates_with_myc_server() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new(relay.url(), MycConnectionApproval::NotRequired); - let runtime = test_runtime.runtime.clone(); - let signer_public_key = runtime.signer_identity().public_key(); - let final_signer_public_key = connect_public_key(signer_public_key); - let target = Target::try_new( - final_signer_public_key, - vec![ConnectRelayUrl::parse(relay.url())?], - )?; - let client = Client::from_secret( - "3333333333333333333333333333333333333333333333333333333333333333", - target, - )?; - - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let service_runtime = runtime.clone(); - let listener_task = tokio::spawn(async move { - service_runtime - .run_until(async { - let _ = shutdown_rx.await; - }) - .await - }); - relay.wait_for_subscription_count(1).await?; - - let mut connect = client.prepare( - RequestId::parse("final-client-connect")?, - Request::Connect { - remote_signer_public_key: final_signer_public_key, - secret: None, - requested_permissions: Permissions::default(), - client_metadata: None, - }, - )?; - let request = Event::from_json(connect.publication()?.as_json())?; - publish_event(relay.url(), &request).await?; - connect.mark_published()?; - let responses = relay - .wait_for_published_events_by_author(signer_public_key, 1) - .await?; - let response = ClientEvent::from_json(&responses[0].as_json())?; - assert_eq!( - connect.select(&response)?, - EventOutcome::Complete(Box::new(Response::ConnectAcknowledged)) - ); - - let mut ping = client.prepare(RequestId::parse("final-client-ping")?, Request::Ping)?; - let request = Event::from_json(ping.publication()?.as_json())?; - publish_event(relay.url(), &request).await?; - ping.mark_published()?; - let responses = relay - .wait_for_published_events_by_author(signer_public_key, 2) - .await?; - let response = ClientEvent::from_json(&responses[1].as_json())?; - assert_eq!( - ping.select(&response)?, - EventOutcome::Complete(Box::new(Response::Pong)) - ); - - let _ = shutdown_tx.send(()); - listener_task.await??; - Ok(()) -} - -fn register_external_client_session( - runtime: &MycRuntime, - client_public_key: PublicKey, - relay_url: &str, - permissions: &str, -) -> TestResult<()> { - let manager = runtime.signer_manager()?; - let requested_permissions: radroots_nostr_connect::permission::Permissions = - if permissions.trim().is_empty() { - Default::default() - } else { - permissions.parse()? - }; - let connection = manager.register_connection( - RadrootsNostrSignerConnectionDraft::new(client_public_key, runtime.user_public_identity()) - .with_requested_permissions(requested_permissions.clone()) - .with_relays(vec![relay_url.parse()?]) - .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::NotRequired), - )?; - let _ = manager.set_granted_permissions(&connection.connection_id, requested_permissions)?; - Ok(()) -} - -async fn publish_event(relay_url: &str, event: &Event) -> TestResult<()> { - let (mut websocket, _) = tokio_tungstenite::connect_async(relay_url).await?; - websocket - .send(Message::Text( - ClientMessage::event(event.clone()).as_json().into(), - )) - .await?; - - while let Some(message) = websocket.next().await { - let message = message?; - let Message::Text(text) = message else { - continue; - }; - let relay_message = RelayMessage::from_json(text.as_str())?; - if let RelayMessage::Ok { - event_id, - status, - message, - } = relay_message - { - assert_eq!(event_id, event.id); - assert!(status, "client publish rejected: {message}"); - return Ok(()); - } - } - - Err("relay connection closed before OK".into()) -} - -async fn wait_for_connection_count(runtime: &MycRuntime, expected: usize) -> TestResult<()> { - timeout(RUNTIME_STATE_TIMEOUT, async { - loop { - if runtime - .signer_manager() - .expect("manager") - .list_connections() - .expect("connections") - .len() - >= expected - { - return; - } - sleep(POLL_INTERVAL).await; - } - }) - .await?; - Ok(()) -} - -async fn wait_for_client_connection_status( - runtime: &MycRuntime, - client_public_key: PublicKey, - expected: RadrootsNostrSignerConnectionStatus, -) -> TestResult<()> { - timeout(RUNTIME_STATE_TIMEOUT, async { - loop { - let matches = runtime - .signer_manager() - .expect("manager") - .find_connections_by_client_public_key(&client_public_key) - .expect("connections"); - if matches - .iter() - .any(|connection| connection.status == expected) - { - return; - } - sleep(POLL_INTERVAL).await; - } - }) - .await?; - Ok(()) -} - -async fn wait_for_connect_secret_consumed(runtime: &MycRuntime) -> TestResult<()> { - timeout(RUNTIME_STATE_TIMEOUT, async { - loop { - let consumed = runtime - .signer_manager() - .expect("manager") - .list_connections() - .expect("connections") - .into_iter() - .any(|connection| connection.connect_secret_is_consumed()); - if consumed { - return; - } - sleep(POLL_INTERVAL).await; - } - }) - .await?; - Ok(()) -} - -async fn wait_for_operation_audit_count( - runtime: &MycRuntime, - expected: usize, -) -> TestResult<Vec<MycOperationAuditRecord>> { - timeout(RUNTIME_STATE_TIMEOUT, async { - loop { - let records = runtime - .operation_audit_store() - .list() - .expect("operation audit"); - if records.len() >= expected { - return records; - } - sleep(POLL_INTERVAL).await; - } - }) - .await - .map_err(Into::into) -} - -async fn wait_for_delivery_outbox_records<F>( - runtime: &MycRuntime, - predicate: F, -) -> TestResult<Vec<MycDeliveryOutboxRecord>> -where - F: Fn(&[MycDeliveryOutboxRecord]) -> bool, -{ - timeout(RUNTIME_STATE_TIMEOUT, async { - loop { - let records = runtime - .delivery_outbox_store() - .list_all() - .expect("delivery outbox"); - if predicate(&records) { - return records; - } - sleep(POLL_INTERVAL).await; - } - }) - .await - .map_err(Into::into) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn live_listener_rejects_denied_clients_without_registering_connection() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let client_identity = - identity("7777777777777777777777777777777777777777777777777777777777777777"); - let test_runtime = MycTestRuntime::new_with_transport_config( - &[relay.url()], - MycConnectionApproval::ExplicitUser, - |config| { - config.policy.denied_client_pubkeys = vec![client_identity.public_key().to_hex()]; - }, - ); - let runtime = test_runtime.runtime.clone(); - let signer_public_key = runtime.signer_identity().public_key(); - - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let service_runtime = runtime.clone(); - let listener_task = tokio::spawn(async move { - service_runtime - .run_until(async { - let _ = shutdown_rx.await; - }) - .await - }); - - relay.wait_for_subscription_count(1).await?; - - let request_event = build_request_event( - &client_identity, - signer_public_key, - connect_request_message("denied-connect", signer_public_key, "denied-secret"), - Timestamp::now().as_secs(), - ); - publish_event(relay.url(), &request_event).await?; - - let response_events = relay - .wait_for_published_events_by_author(signer_public_key, 1) - .await?; - let response = decrypt_response(&client_identity, signer_public_key, &response_events[0]); - assert_eq!(response.id, "denied-connect"); - let parsed = radroots_nostr_connect::Response::from_envelope( - &Request::Connect { - remote_signer_public_key: connect_public_key(signer_public_key), - secret: Some("denied-secret".to_owned()), - requested_permissions: Default::default(), - client_metadata: None, - } - .method(), - response, - )?; - assert_eq!( - parsed, - radroots_nostr_connect::Response::Error { - result: None, - error: "client public key denied by policy".to_owned(), - } - ); - assert!(runtime.signer_manager()?.list_connections()?.is_empty()); - - let _ = shutdown_tx.send(()); - listener_task.await??; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn live_listener_discards_malformed_and_replayed_request_events() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new(relay.url(), MycConnectionApproval::NotRequired); - let runtime = test_runtime.runtime.clone(); - let signer_public_key = runtime.signer_identity().public_key(); - let client_identity = - identity("3434343434343434343434343434343434343434343434343434343434343434"); - let other_identity = - identity("3535353535353535353535353535353535353535353535353535353535353535"); - let base_created_at = Timestamp::now().as_secs(); - - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let service_runtime = runtime.clone(); - let listener_task = tokio::spawn(async move { - service_runtime - .run_until(async { - let _ = shutdown_rx.await; - }) - .await - }); - relay.wait_for_subscription_count(1).await?; - - let mut invalid_author = build_request_event( - &client_identity, - signer_public_key, - connect_request_message("invalid-author", signer_public_key, "invalid-author-secret"), - base_created_at, - ); - invalid_author.pubkey = other_identity.public_key(); - publish_event(relay.url(), &invalid_author).await?; - - let wrong_recipient = build_request_event_with_recipient( - &client_identity, - signer_public_key, - other_identity.public_key(), - connect_request_message( - "wrong-recipient", - signer_public_key, - "wrong-recipient-secret", - ), - base_created_at + 1, - ); - publish_event(relay.url(), &wrong_recipient).await?; - - let invalid_request_id = build_request_event_payload( - &client_identity, - signer_public_key, - signer_public_key, - &serde_json::json!({ - "id": "", - "method": "connect", - "params": [signer_public_key.to_hex(), "invalid-request-id-secret"] - }) - .to_string(), - base_created_at + 2, - ); - publish_event(relay.url(), &invalid_request_id).await?; - - let malformed_ciphertext = EventBuilder::new(Kind::Custom(RPC_KIND), "not-nip44-ciphertext") - .tags([Tag::public_key(signer_public_key)]) - .custom_created_at(Timestamp::from(base_created_at + 3)) - .sign_with_keys(client_identity.keys())?; - publish_event(relay.url(), &malformed_ciphertext).await?; - - sleep(Duration::from_millis(200)).await; - assert!(runtime.signer_manager()?.list_connections()?.is_empty()); - assert!( - relay - .published_events_by_author(signer_public_key) - .await - .is_empty() - ); - - let valid_request = build_request_event( - &client_identity, - signer_public_key, - connect_request_message("valid-after-invalid", signer_public_key, "valid-secret"), - base_created_at + 4, - ); - publish_event(relay.url(), &valid_request).await?; - let responses = relay - .wait_for_published_events_by_author(signer_public_key, 1) - .await?; - assert_eq!(responses.len(), 1); - assert_eq!( - decrypt_response(&client_identity, signer_public_key, &responses[0]).id, - "valid-after-invalid" - ); - wait_for_connection_count(&runtime, 1).await?; - - publish_event(relay.url(), &valid_request).await?; - sleep(Duration::from_millis(200)).await; - assert_eq!( - relay - .published_events_by_author(signer_public_key) - .await - .len(), - 1 - ); - assert_eq!(runtime.delivery_outbox_store().list_all()?.len(), 1); - - let _ = shutdown_tx.send(()); - listener_task.await??; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn live_listener_enforces_signing_ceiling_and_switch_relay_permission() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new_with_transport_config( - &[relay.url()], - MycConnectionApproval::NotRequired, - |config| { - config.policy.permission_ceiling = "get_public_key,sign_event:1,switch_relays" - .parse() - .expect("permission ceiling"); - config.policy.allowed_sign_event_kinds = vec![1]; - }, - ); - let runtime = test_runtime.runtime.clone(); - let signer_public_key = runtime.signer_identity().public_key(); - let user_public_key = runtime.user_identity().public_key(); - assert_ne!(signer_public_key, user_public_key); - let client_identity = - identity("3636363636363636363636363636363636363636363636363636363636363636"); - let base_created_at = Timestamp::now().as_secs(); - - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let service_runtime = runtime.clone(); - let listener_task = tokio::spawn(async move { - service_runtime - .run_until(async { - let _ = shutdown_rx.await; - }) - .await - }); - relay.wait_for_subscription_count(1).await?; - - let connect_request = Request::Connect { - remote_signer_public_key: connect_public_key(signer_public_key), - secret: None, - requested_permissions: "get_public_key,sign_event:1,sign_event:7,switch_relays".parse()?, - client_metadata: None, - }; - publish_event( - relay.url(), - &build_request_event( - &client_identity, - signer_public_key, - RequestMessage::new("policy-connect", connect_request.clone()), - base_created_at, - ), - ) - .await?; - let responses = relay - .wait_for_published_events_by_author(signer_public_key, 1) - .await?; - let connect_response = Response::from_envelope( - &connect_request.method(), - decrypt_response(&client_identity, signer_public_key, &responses[0]), - )?; - assert_eq!(connect_response, Response::ConnectAcknowledged); - let connection = runtime - .signer_manager()? - .list_connections()? - .into_iter() - .next() - .expect("connection"); - assert_eq!( - connection.granted_permissions().to_string(), - "get_public_key,sign_event:1,switch_relays" - ); - - let get_public_key_request = Request::GetPublicKey; - publish_event( - relay.url(), - &build_request_event( - &client_identity, - signer_public_key, - RequestMessage::new("policy-get-public-key", get_public_key_request.clone()), - base_created_at + 1, - ), - ) - .await?; - let responses = relay - .wait_for_published_events_by_author(signer_public_key, 2) - .await?; - assert_eq!(responses[1].pubkey, signer_public_key); - assert_eq!( - Response::from_envelope( - &get_public_key_request.method(), - decrypt_response(&client_identity, signer_public_key, &responses[1]), - )?, - Response::UserPublicKey(connect_public_key(user_public_key)) - ); - - let unsigned_event = |kind: u16, content: &str| { - connect_unsigned_event(user_public_key, base_created_at, kind, content) - }; - let allowed_sign_request = Request::SignEvent(unsigned_event(1, "allowed")); - publish_event( - relay.url(), - &build_request_event( - &client_identity, - signer_public_key, - RequestMessage::new("policy-sign-allowed", allowed_sign_request.clone()), - base_created_at + 2, - ), - ) - .await?; - let responses = relay - .wait_for_published_events_by_author(signer_public_key, 3) - .await?; - let allowed_response = Response::from_envelope( - &allowed_sign_request.method(), - decrypt_response(&client_identity, signer_public_key, &responses[2]), - )?; - let Response::SignedEvent(signed_event) = allowed_response else { - panic!("expected signed event response"); - }; - let signed_event: Event = serde_json::from_str(&signed_event.as_json())?; - assert_eq!(signed_event.pubkey, user_public_key); - signed_event.verify()?; - - let denied_sign_request = Request::SignEvent(unsigned_event(7, "denied")); - publish_event( - relay.url(), - &build_request_event( - &client_identity, - signer_public_key, - RequestMessage::new("policy-sign-denied", denied_sign_request.clone()), - base_created_at + 3, - ), - ) - .await?; - let responses = relay - .wait_for_published_events_by_author(signer_public_key, 4) - .await?; - assert!(matches!( - Response::from_envelope( - &denied_sign_request.method(), - decrypt_response(&client_identity, signer_public_key, &responses[3]), - )?, - Response::Error { error, .. } - if error.contains("outside the configured policy ceiling") - )); - - let switch_request = Request::SwitchRelays; - publish_event( - relay.url(), - &build_request_event( - &client_identity, - signer_public_key, - RequestMessage::new("policy-switch", switch_request.clone()), - base_created_at + 4, - ), - ) - .await?; - let responses = relay - .wait_for_published_events_by_author(signer_public_key, 5) - .await?; - assert_eq!( - Response::from_envelope( - &switch_request.method(), - decrypt_response(&client_identity, signer_public_key, &responses[4]), - )?, - Response::RelayList(vec![relay.url().parse()?]) - ); - - let _ = shutdown_tx.send(()); - listener_task.await??; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn external_nostr_client_compatibility_covers_connect_and_base_methods() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new(relay.url(), MycConnectionApproval::NotRequired); - let runtime = test_runtime.runtime.clone(); - let signer_public_key = runtime.signer_identity().public_key(); - let user_public_key = runtime.user_identity().public_key(); - let client_identity = - identity("3333333333333333333333333333333333333333333333333333333333333333"); - let base_created_at = Timestamp::now().as_secs(); - - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let service_runtime = runtime.clone(); - let listener_task = tokio::spawn(async move { - service_runtime - .run_until(async { - let _ = shutdown_rx.await; - }) - .await - }); - - relay.wait_for_subscription_count(1).await?; - - let (_, connect_response) = publish_external_request_and_wait_for_response( - &relay, - &client_identity, - signer_public_key, - "external-connect", - ExternalNostrConnectRequest::Connect { - remote_signer_public_key: signer_public_key, - secret: None, - }, - base_created_at, - ) - .await?; - assert_eq!(connect_response.result, Some(ExternalResponseResult::Ack)); - assert_eq!(connect_response.error, None); - - wait_for_connection_count(&runtime, 1).await?; - - let (_, get_public_key_response) = publish_external_request_and_wait_for_response( - &relay, - &client_identity, - signer_public_key, - "external-get-public-key", - ExternalNostrConnectRequest::GetPublicKey, - base_created_at + 1, - ) - .await?; - assert_eq!( - get_public_key_response.result, - Some(ExternalResponseResult::GetPublicKey(user_public_key)) - ); - assert_eq!(get_public_key_response.error, None); - - let (_, ping_response) = publish_external_request_and_wait_for_response( - &relay, - &client_identity, - signer_public_key, - "external-ping", - ExternalNostrConnectRequest::Ping, - base_created_at + 2, - ) - .await?; - assert_eq!(ping_response.result, Some(ExternalResponseResult::Pong)); - assert_eq!(ping_response.error, None); - - let _ = shutdown_tx.send(()); - listener_task.await??; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn external_nostr_client_compatibility_covers_signed_and_crypto_methods() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new(relay.url(), MycConnectionApproval::NotRequired); - let runtime = test_runtime.runtime.clone(); - let signer_public_key = runtime.signer_identity().public_key(); - let user_public_key = runtime.user_identity().public_key(); - let client_identity = - identity("3333333333333333333333333333333333333333333333333333333333333333"); - let peer_identity = - identity("4444444444444444444444444444444444444444444444444444444444444444"); - let base_created_at = Timestamp::now().as_secs(); - - register_external_client_session( - &runtime, - client_identity.public_key(), - relay.url(), - "sign_event:1,nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt", - )?; - - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let service_runtime = runtime.clone(); - let listener_task = tokio::spawn(async move { - service_runtime - .run_until(async { - let _ = shutdown_rx.await; - }) - .await - }); - - relay.wait_for_subscription_count(1).await?; - - let unsigned_event: UnsignedEvent = serde_json::from_value(serde_json::json!({ - "pubkey": user_public_key.to_hex(), - "created_at": base_created_at, - "kind": 1, - "tags": [], - "content": "hello from an external nostr client" - }))?; - let (_, sign_event_response) = publish_external_request_and_wait_for_response( - &relay, - &client_identity, - signer_public_key, - "external-sign-event", - ExternalNostrConnectRequest::SignEvent(unsigned_event.clone()), - base_created_at, - ) - .await?; - let signed_event = sign_event_response - .result - .expect("sign_event result") - .to_sign_event()?; - assert_eq!(signed_event.pubkey, user_public_key); - assert_eq!(signed_event.kind, unsigned_event.kind); - assert_eq!(signed_event.content, unsigned_event.content); - signed_event.verify()?; - - let (_, nip04_encrypt_response) = publish_external_request_and_wait_for_response( - &relay, - &client_identity, - signer_public_key, - "external-nip04-encrypt", - ExternalNostrConnectRequest::Nip04Encrypt { - public_key: peer_identity.public_key(), - text: "hello via nip04".to_owned(), - }, - base_created_at + 1, - ) - .await?; - let nip04_ciphertext = nip04_encrypt_response - .result - .expect("nip04 encrypt result") - .to_nip04_encrypt()?; - let nip04_plaintext = nostr::nips::nip04::decrypt( - peer_identity.keys().secret_key(), - &user_public_key, - nip04_ciphertext.clone(), - )?; - assert_eq!(nip04_plaintext, "hello via nip04"); - - let nip04_reply_ciphertext = nostr::nips::nip04::encrypt( - peer_identity.keys().secret_key(), - &user_public_key, - "reply via nip04", - )?; - let (_, nip04_decrypt_response) = publish_external_request_and_wait_for_response( - &relay, - &client_identity, - signer_public_key, - "external-nip04-decrypt", - ExternalNostrConnectRequest::Nip04Decrypt { - public_key: peer_identity.public_key(), - ciphertext: nip04_reply_ciphertext, - }, - base_created_at + 2, - ) - .await?; - assert_eq!( - nip04_decrypt_response - .result - .expect("nip04 decrypt result") - .to_nip04_decrypt()?, - "reply via nip04" - ); - - let (_, nip44_encrypt_response) = publish_external_request_and_wait_for_response( - &relay, - &client_identity, - signer_public_key, - "external-nip44-encrypt", - ExternalNostrConnectRequest::Nip44Encrypt { - public_key: peer_identity.public_key(), - text: "hello via nip44".to_owned(), - }, - base_created_at + 3, - ) - .await?; - let nip44_ciphertext = nip44_encrypt_response - .result - .expect("nip44 encrypt result") - .to_nip44_encrypt()?; - let nip44_plaintext = nip44::decrypt( - peer_identity.keys().secret_key(), - &user_public_key, - &nip44_ciphertext, - )?; - assert_eq!(nip44_plaintext, "hello via nip44"); - - let nip44_reply_ciphertext = nip44::encrypt( - peer_identity.keys().secret_key(), - &user_public_key, - "reply via nip44", - Version::V2, - )?; - let (_, nip44_decrypt_response) = publish_external_request_and_wait_for_response( - &relay, - &client_identity, - signer_public_key, - "external-nip44-decrypt", - ExternalNostrConnectRequest::Nip44Decrypt { - public_key: peer_identity.public_key(), - ciphertext: nip44_reply_ciphertext, - }, - base_created_at + 4, - ) - .await?; - assert_eq!( - nip44_decrypt_response - .result - .expect("nip44 decrypt result") - .to_nip44_decrypt()?, - "reply via nip44" - ); - - let _ = shutdown_tx.send(()); - listener_task.await??; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn external_nostr_client_surfaces_pending_approval_state() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new(relay.url(), MycConnectionApproval::ExplicitUser); - let runtime = test_runtime.runtime.clone(); - let signer_public_key = runtime.signer_identity().public_key(); - let client_identity = - identity("8888888888888888888888888888888888888888888888888888888888888888"); - let base_created_at = Timestamp::now().as_secs(); - - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let service_runtime = runtime.clone(); - let listener_task = tokio::spawn(async move { - service_runtime - .run_until(async { - let _ = shutdown_rx.await; - }) - .await - }); - - relay.wait_for_subscription_count(1).await?; - - let (_, connect_response) = publish_external_request_and_wait_for_response( - &relay, - &client_identity, - signer_public_key, - "external-explicit-connect", - ExternalNostrConnectRequest::Connect { - remote_signer_public_key: signer_public_key, - secret: None, - }, - base_created_at, - ) - .await?; - assert_eq!(connect_response.result, Some(ExternalResponseResult::Ack)); - - wait_for_connection_count(&runtime, 1).await?; - - let (_, pending_response) = publish_external_request_and_wait_for_response( - &relay, - &client_identity, - signer_public_key, - "external-pending-get-public-key", - ExternalNostrConnectRequest::GetPublicKey, - base_created_at + 1, - ) - .await?; - assert_eq!(pending_response.result, None); - assert_eq!( - pending_response.error.as_deref(), - Some("connection is pending") - ); - - let _ = shutdown_tx.send(()); - listener_task.await??; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn external_nostr_client_surfaces_auth_challenge_state() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let client_identity = - identity("8989898989898989898989898989898989898989898989898989898989898989"); - let test_runtime = MycTestRuntime::new(relay.url(), MycConnectionApproval::NotRequired); - let runtime = test_runtime.runtime.clone(); - let signer_public_key = runtime.signer_identity().public_key(); - let base_created_at = Timestamp::now().as_secs(); - - register_external_client_session(&runtime, client_identity.public_key(), relay.url(), "")?; - let connection_id = runtime - .signer_manager()? - .list_connections()? - .into_iter() - .find(|connection| connection.client_public_key == client_identity.public_key()) - .expect("active connection") - .connection_id; - let _ = runtime - .signer_manager()? - .require_auth_challenge(&connection_id, "https://auth.example/challenge")?; - - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let service_runtime = runtime.clone(); - let listener_task = tokio::spawn(async move { - service_runtime - .run_until(async { - let _ = shutdown_rx.await; - }) - .await - }); - - relay.wait_for_subscription_count(1).await?; - - let (_, connect_response) = publish_external_request_and_wait_for_response( - &relay, - &client_identity, - signer_public_key, - "external-auth-ping", - ExternalNostrConnectRequest::Ping, - base_created_at, - ) - .await?; - assert_eq!( - connect_response.result, - Some(ExternalResponseResult::AuthUrl) - ); - assert_eq!( - connect_response.error.as_deref(), - Some("https://auth.example/challenge") - ); - - let _ = shutdown_tx.send(()); - listener_task.await??; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn external_nostr_client_ignores_unrelated_signer_events_before_response() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new(relay.url(), MycConnectionApproval::NotRequired); - let runtime = test_runtime.runtime.clone(); - let signer_identity = runtime.signer_identity(); - let signer_public_key = signer_identity.public_key(); - let client_identity = - identity("5656565656565656565656565656565656565656565656565656565656565656"); - let base_created_at = Timestamp::now().as_secs(); - - register_external_client_session(&runtime, client_identity.public_key(), relay.url(), "")?; - - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let service_runtime = runtime.clone(); - let listener_task = tokio::spawn(async move { - service_runtime - .run_until(async { - let _ = shutdown_rx.await; - }) - .await - }); - - relay.wait_for_subscription_count(1).await?; - - let noise_event = build_signer_noise_event(signer_identity, base_created_at); - publish_event(relay.url(), &noise_event).await?; - - let (_, ping_response) = publish_external_request_and_wait_for_response( - &relay, - &client_identity, - signer_public_key, - "external-noise-ping", - ExternalNostrConnectRequest::Ping, - base_created_at + 1, - ) - .await?; - assert_eq!(ping_response.result, Some(ExternalResponseResult::Pong)); - assert_eq!(ping_response.error, None); - - let _ = shutdown_tx.send(()); - listener_task.await??; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn live_listener_consumes_connect_secret_only_after_successful_publish() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new(relay.url(), MycConnectionApproval::NotRequired); - let runtime = test_runtime.runtime.clone(); - let signer_public_key = runtime.signer_identity().public_key(); - let client_identity = - identity("3333333333333333333333333333333333333333333333333333333333333333"); - let base_created_at = Timestamp::now().as_secs(); - - relay - .queue_publish_outcomes(signer_public_key, &[false, true]) - .await; - - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let service_runtime = runtime.clone(); - let listener_task = tokio::spawn(async move { - service_runtime - .run_until(async { - let _ = shutdown_rx.await; - }) - .await - }); - - relay.wait_for_subscription_count(1).await?; - - let request_one = build_request_event( - &client_identity, - signer_public_key, - connect_request_message("connect-1", signer_public_key, "shared-secret"), - base_created_at, - ); - publish_event(relay.url(), &request_one).await?; - wait_for_connection_count(&runtime, 1).await?; - sleep(Duration::from_millis(100)).await; - - assert!( - relay - .published_events_by_author(signer_public_key) - .await - .is_empty() - ); - let initial_connection = runtime - .signer_manager()? - .list_connections()? - .into_iter() - .next() - .expect("stored connection"); - assert!(!initial_connection.connect_secret_is_consumed()); - let operation_audit = wait_for_operation_audit_count(&runtime, 1).await?; - assert_eq!(operation_audit.len(), 1); - assert_eq!( - operation_audit[0].operation, - MycOperationAuditKind::ListenerResponsePublish - ); - assert_eq!( - operation_audit[0].outcome, - MycOperationAuditOutcome::Rejected - ); - assert_eq!( - operation_audit[0].connection_id.as_deref(), - Some(initial_connection.connection_id.as_str()) - ); - assert_eq!(operation_audit[0].request_id.as_deref(), Some("connect-1")); - assert_eq!(operation_audit[0].relay_count, 1); - assert_eq!(operation_audit[0].acknowledged_relay_count, 0); - assert!( - operation_audit[0] - .relay_outcome_summary - .contains("blocked by test relay") - ); - let outbox_records = wait_for_delivery_outbox_records(&runtime, |records| { - !records.is_empty() && records[0].status == MycDeliveryOutboxStatus::Failed - }) - .await?; - assert_eq!( - outbox_records[0].kind, - MycDeliveryOutboxKind::ListenerResponsePublish - ); - assert_eq!(outbox_records[0].status, MycDeliveryOutboxStatus::Failed); - assert_eq!( - outbox_records[0] - .connection_id - .as_ref() - .map(|value| value.as_str()), - Some(initial_connection.connection_id.as_str()) - ); - assert_eq!(outbox_records[0].request_id.as_deref(), Some("connect-1")); - assert!(outbox_records[0].signer_publish_workflow_id.is_some()); - assert!( - runtime - .signer_manager()? - .list_publish_workflows()? - .is_empty() - ); - - let request_two = build_request_event( - &client_identity, - signer_public_key, - connect_request_message("connect-2", signer_public_key, "shared-secret"), - base_created_at + 1, - ); - publish_event(relay.url(), &request_two).await?; - - let response_events = relay - .wait_for_published_events_by_author(signer_public_key, 1) - .await?; - let response = decrypt_response(&client_identity, signer_public_key, &response_events[0]); - assert_eq!(response.id, "connect-2"); - assert_eq!( - response.result, - Some(serde_json::Value::String("shared-secret".to_owned())) - ); - - wait_for_connect_secret_consumed(&runtime).await?; - let consumed_connection = runtime - .signer_manager()? - .list_connections()? - .into_iter() - .next() - .expect("stored connection"); - assert!(consumed_connection.connect_secret_is_consumed()); - let outbox_records = wait_for_delivery_outbox_records(&runtime, |records| { - records.len() >= 2 && records[1].status == MycDeliveryOutboxStatus::Finalized - }) - .await?; - assert_eq!( - outbox_records[1].kind, - MycDeliveryOutboxKind::ListenerResponsePublish - ); - assert_eq!(outbox_records[1].status, MycDeliveryOutboxStatus::Finalized); - assert_eq!(outbox_records[1].request_id.as_deref(), Some("connect-2")); - assert!(outbox_records[1].published_at_unix.is_some()); - assert!(outbox_records[1].finalized_at_unix.is_some()); - assert!(outbox_records[1].signer_publish_workflow_id.is_some()); - assert!( - runtime - .signer_manager()? - .list_publish_workflows()? - .is_empty() - ); - - let request_three = build_request_event( - &client_identity, - signer_public_key, - connect_request_message("connect-3", signer_public_key, "shared-secret"), - base_created_at + 2, - ); - publish_event(relay.url(), &request_three).await?; - sleep(Duration::from_millis(300)).await; - - assert_eq!( - relay - .published_events_by_author(signer_public_key) - .await - .len(), - 1 - ); - let operation_audit = runtime.operation_audit_store().list()?; - assert_eq!(operation_audit.len(), 2); - assert_eq!( - operation_audit[1].operation, - MycOperationAuditKind::ListenerResponsePublish - ); - assert_eq!( - operation_audit[1].outcome, - MycOperationAuditOutcome::Succeeded - ); - assert_eq!(operation_audit[1].request_id.as_deref(), Some("connect-2")); - - let _ = shutdown_tx.send(()); - listener_task.await??; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn live_listener_acknowledges_logout_before_revoking_session() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new(relay.url(), MycConnectionApproval::NotRequired); - let runtime = test_runtime.runtime.clone(); - let signer_public_key = runtime.signer_identity().public_key(); - let client_identity = - identity("3636363636363636363636363636363636363636363636363636363636363636"); - let base_created_at = Timestamp::now().as_secs(); - - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let service_runtime = runtime.clone(); - let listener_task = tokio::spawn(async move { - service_runtime - .run_until(async { - let _ = shutdown_rx.await; - }) - .await - }); - relay.wait_for_subscription_count(1).await?; - - let connect = build_request_event( - &client_identity, - signer_public_key, - connect_request_message("logout-connect", signer_public_key, "logout-secret"), - base_created_at, - ); - publish_event(relay.url(), &connect).await?; - let responses = relay - .wait_for_published_events_by_author(signer_public_key, 1) - .await?; - let connect_response = decrypt_response(&client_identity, signer_public_key, &responses[0]); - assert_eq!(connect_response.id, "logout-connect"); - - let logout = build_request_event( - &client_identity, - signer_public_key, - RequestMessage::new("logout-request", Request::Logout), - base_created_at + 1, - ); - publish_event(relay.url(), &logout).await?; - let responses = relay - .wait_for_published_events_by_author(signer_public_key, 2) - .await?; - let logout_response = decrypt_response(&client_identity, signer_public_key, &responses[1]); - let logout_response = Response::from_envelope(&Request::Logout.method(), logout_response)?; - assert_eq!(logout_response, Response::LogoutAcknowledged); - wait_for_client_connection_status( - &runtime, - client_identity.public_key(), - RadrootsNostrSignerConnectionStatus::Revoked, - ) - .await?; - let logout_outbox = wait_for_delivery_outbox_records(&runtime, |records| { - records.iter().any(|record| { - record.kind == MycDeliveryOutboxKind::LogoutAcknowledgementPublish - && record.status == MycDeliveryOutboxStatus::Finalized - }) - }) - .await?; - let logout_outbox = logout_outbox - .iter() - .find(|record| record.kind == MycDeliveryOutboxKind::LogoutAcknowledgementPublish) - .expect("logout acknowledgement outbox record"); - assert_eq!(logout_outbox.request_id.as_deref(), Some("logout-request")); - assert!(logout_outbox.signer_publish_workflow_id.is_none()); - - let repeated_logout = build_request_event( - &client_identity, - signer_public_key, - RequestMessage::new("repeated-logout", Request::Logout), - base_created_at + 2, - ); - publish_event(relay.url(), &repeated_logout).await?; - let responses = relay - .wait_for_published_events_by_author(signer_public_key, 3) - .await?; - let repeated_logout_response = - decrypt_response(&client_identity, signer_public_key, &responses[2]); - let repeated_logout_response = - Response::from_envelope(&Request::Logout.method(), repeated_logout_response)?; - assert_eq!( - repeated_logout_response, - Response::Error { - result: None, - error: "unauthorized".to_owned(), - } - ); - - let ping = build_request_event( - &client_identity, - signer_public_key, - ping_request_message("post-logout-ping"), - base_created_at + 3, - ); - publish_event(relay.url(), &ping).await?; - let responses = relay - .wait_for_published_events_by_author(signer_public_key, 4) - .await?; - let ping_response = decrypt_response(&client_identity, signer_public_key, &responses[3]); - let ping_response = Response::from_envelope(&Request::Ping.method(), ping_response)?; - assert_eq!( - ping_response, - Response::Error { - result: None, - error: "unauthorized".to_owned(), - } - ); - - let reconnect = build_request_event( - &client_identity, - signer_public_key, - connect_request_message("logout-reconnect", signer_public_key, "new-logout-secret"), - base_created_at + 4, - ); - publish_event(relay.url(), &reconnect).await?; - let responses = relay - .wait_for_published_events_by_author(signer_public_key, 5) - .await?; - let reconnect_response = decrypt_response(&client_identity, signer_public_key, &responses[4]); - assert_eq!(reconnect_response.id, "logout-reconnect"); - let connections = runtime - .signer_manager()? - .find_connections_by_client_public_key(&client_identity.public_key())?; - assert_eq!(connections.len(), 2); - assert_eq!( - connections - .iter() - .filter(|connection| connection.status == RadrootsNostrSignerConnectionStatus::Revoked) - .count(), - 1 - ); - assert_eq!( - connections - .iter() - .filter(|connection| connection.status == RadrootsNostrSignerConnectionStatus::Active) - .count(), - 1 - ); - - let _ = shutdown_tx.send(()); - listener_task.await??; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn failed_logout_publish_is_retried_and_revoked_during_startup_recovery() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new(relay.url(), MycConnectionApproval::NotRequired); - let MycTestRuntime { - _temp: _tempdir, - runtime, - } = test_runtime; - let config = runtime.config().clone(); - let signer_public_key = runtime.signer_identity().public_key(); - let client_identity = - identity("3737373737373737373737373737373737373737373737373737373737373737"); - let base_created_at = Timestamp::now().as_secs(); - relay - .queue_publish_outcomes(signer_public_key, &[true, false, true]) - .await; - - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let service_runtime = runtime.clone(); - let listener_task = tokio::spawn(async move { - service_runtime - .run_until(async { - let _ = shutdown_rx.await; - }) - .await - }); - relay.wait_for_subscription_count(1).await?; - - let connect = build_request_event( - &client_identity, - signer_public_key, - connect_request_message("recovery-connect", signer_public_key, "recovery-secret"), - base_created_at, - ); - publish_event(relay.url(), &connect).await?; - relay - .wait_for_published_events_by_author(signer_public_key, 1) - .await?; - - let logout = build_request_event( - &client_identity, - signer_public_key, - RequestMessage::new("recovery-logout", Request::Logout), - base_created_at + 1, - ); - publish_event(relay.url(), &logout).await?; - let failed_outbox = wait_for_delivery_outbox_records(&runtime, |records| { - records.iter().any(|record| { - record.kind == MycDeliveryOutboxKind::LogoutAcknowledgementPublish - && record.status == MycDeliveryOutboxStatus::Failed - }) - }) - .await?; - assert_eq!( - failed_outbox - .iter() - .find(|record| record.kind == MycDeliveryOutboxKind::LogoutAcknowledgementPublish) - .and_then(|record| record.request_id.as_deref()), - Some("recovery-logout") - ); - assert_eq!( - runtime - .signer_manager()? - .find_connections_by_client_public_key(&client_identity.public_key())?[0] - .status, - RadrootsNostrSignerConnectionStatus::Active - ); - assert_eq!( - relay - .published_events_by_author(signer_public_key) - .await - .len(), - 1 - ); - - let _ = shutdown_tx.send(()); - listener_task.await??; - - let restarted_runtime = MycRuntime::bootstrap(config)?; - restarted_runtime.clone().run_until(async {}).await?; - let responses = relay - .wait_for_published_events_by_author(signer_public_key, 2) - .await?; - let recovered_response = decrypt_response(&client_identity, signer_public_key, &responses[1]); - let recovered_response = - Response::from_envelope(&Request::Logout.method(), recovered_response)?; - assert_eq!(recovered_response, Response::LogoutAcknowledged); - let recovered_connection = restarted_runtime - .signer_manager()? - .find_connections_by_client_public_key(&client_identity.public_key())? - .into_iter() - .next() - .expect("recovered connection"); - assert_eq!( - recovered_connection.status, - RadrootsNostrSignerConnectionStatus::Revoked - ); - let recovered_outbox = restarted_runtime.delivery_outbox_store().list_all()?; - assert!(recovered_outbox.iter().any(|record| { - record.kind == MycDeliveryOutboxKind::LogoutAcknowledgementPublish - && record.status == MycDeliveryOutboxStatus::Finalized - })); - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn published_logout_acknowledgement_is_finalized_without_republish_on_restart() --> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new(relay.url(), MycConnectionApproval::NotRequired); - let MycTestRuntime { - _temp: _tempdir, - runtime, - } = test_runtime; - let config = runtime.config().clone(); - let signer_public_key = runtime.signer_identity().public_key(); - let client_identity = - identity("3838383838383838383838383838383838383838383838383838383838383838"); - let relay_url: RadrootsNostrRelayUrl = relay.url().parse()?; - let connection = runtime.signer_manager()?.register_connection( - RadrootsNostrSignerConnectionDraft::new( - client_identity.public_key(), - runtime.user_public_identity(), - ) - .with_relays(vec![relay_url.clone()]) - .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::NotRequired), - )?; - let acknowledgement_event = runtime - .signer_identity() - .sign_protocol_event_builder( - RadrootsNostrGenericEventBuilder::new( - RadrootsNostrKind::Custom(RPC_KIND), - "published logout acknowledgement fixture", - ), - "published logout acknowledgement fixture", - ) - .map_err(|error| format!("failed to sign logout acknowledgement fixture: {error}"))?; - publish_event(relay.url(), &acknowledgement_event).await?; - let outbox_record = MycDeliveryOutboxRecord::new( - MycDeliveryOutboxKind::LogoutAcknowledgementPublish, - acknowledgement_event, - vec![relay_url], - )? - .with_connection_id(&connection.connection_id) - .with_request_id("published-logout-ack"); - runtime.delivery_outbox_store().enqueue(&outbox_record)?; - runtime - .delivery_outbox_store() - .mark_published_pending_finalize(&outbox_record.job_id, 1)?; - assert_eq!( - runtime - .signer_manager()? - .get_connection(&connection.connection_id)? - .expect("active connection") - .status, - RadrootsNostrSignerConnectionStatus::Active - ); - - let restarted_runtime = MycRuntime::bootstrap(config)?; - restarted_runtime.clone().run_until(async {}).await?; - let recovered_connection = restarted_runtime - .signer_manager()? - .get_connection(&connection.connection_id)? - .expect("recovered connection"); - assert_eq!( - recovered_connection.status, - RadrootsNostrSignerConnectionStatus::Revoked - ); - assert_eq!( - restarted_runtime - .delivery_outbox_store() - .get(&outbox_record.job_id)? - .expect("recovered outbox") - .status, - MycDeliveryOutboxStatus::Finalized - ); - assert_eq!( - relay - .published_events_by_author(signer_public_key) - .await - .len(), - 1 - ); - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn live_listener_works_with_sqlite_signer_state_and_runtime_audit() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new_with_transport_config( - &[relay.url()], - MycConnectionApproval::NotRequired, - |config| { - config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; - config.persistence.runtime_audit_backend = MycRuntimeAuditBackend::Sqlite; - }, - ); - let runtime = test_runtime.runtime.clone(); - let signer_public_key = runtime.signer_identity().public_key(); - let client_identity = - identity("5353535353535353535353535353535353535353535353535353535353535353"); - let base_created_at = Timestamp::now().as_secs(); - - assert_eq!( - runtime - .paths() - .signer_state_path - .file_name() - .and_then(|name| name.to_str()), - Some("signer-state.sqlite") - ); - assert_eq!( - runtime - .paths() - .runtime_audit_path - .file_name() - .and_then(|name| name.to_str()), - Some("operations.sqlite") - ); - - relay - .queue_publish_outcomes(signer_public_key, &[false, true]) - .await; - - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let service_runtime = runtime.clone(); - let listener_task = tokio::spawn(async move { - service_runtime - .run_until(async { - let _ = shutdown_rx.await; - }) - .await - }); - - relay.wait_for_subscription_count(1).await?; - - let client_metadata = ClientMetadata { - requested_permissions: "sign_event:1".parse()?, - name: Some(" SQLite Client ".to_owned()), - url: Some("https://client.example/".to_owned()), - image: Some("https://client.example/icon.png".to_owned()), - }; - let request_one = build_request_event( - &client_identity, - signer_public_key, - connect_request_message_with_metadata( - "sqlite-connect-1", - signer_public_key, - "sqlite-secret", - Some(client_metadata), - ), - base_created_at, - ); - publish_event(relay.url(), &request_one).await?; - wait_for_connection_count(&runtime, 1).await?; - sleep(Duration::from_millis(100)).await; - - assert!( - relay - .published_events_by_author(signer_public_key) - .await - .is_empty() - ); - let initial_connection = runtime - .signer_manager()? - .list_connections()? - .into_iter() - .next() - .expect("stored connection"); - assert!(!initial_connection.connect_secret_is_consumed()); - let stored_metadata = initial_connection - .client_metadata - .as_ref() - .expect("stored client metadata"); - assert_eq!(stored_metadata.name.as_deref(), Some("SQLite Client")); - assert_eq!( - stored_metadata.url.as_deref(), - Some("https://client.example/") - ); - assert_eq!( - stored_metadata.image.as_deref(), - Some("https://client.example/icon.png") - ); - assert!(stored_metadata.requested_permissions.is_empty()); - assert!(initial_connection.requested_permissions.is_empty()); - - let request_two = build_request_event( - &client_identity, - signer_public_key, - connect_request_message("sqlite-connect-2", signer_public_key, "sqlite-secret"), - base_created_at + 1, - ); - publish_event(relay.url(), &request_two).await?; - - let response_events = relay - .wait_for_published_events_by_author(signer_public_key, 1) - .await?; - let response = decrypt_response(&client_identity, signer_public_key, &response_events[0]); - assert_eq!(response.id, "sqlite-connect-2"); - assert_eq!( - response.result, - Some(serde_json::Value::String("sqlite-secret".to_owned())) - ); - - wait_for_connect_secret_consumed(&runtime).await?; - let consumed_connection = runtime - .signer_manager()? - .list_connections()? - .into_iter() - .next() - .expect("stored connection"); - assert!(consumed_connection.connect_secret_is_consumed()); - let operation_audit = wait_for_operation_audit_count(&runtime, 2).await?; - assert_eq!( - operation_audit - .iter() - .filter(|record| record.outcome == MycOperationAuditOutcome::Rejected) - .count(), - 1 - ); - assert_eq!( - operation_audit - .iter() - .filter(|record| record.outcome == MycOperationAuditOutcome::Succeeded) - .count(), - 1 - ); - let outbox_records = wait_for_delivery_outbox_records(&runtime, |records| { - records.len() >= 2 && records[1].status == MycDeliveryOutboxStatus::Finalized - }) - .await?; - assert_eq!(outbox_records[0].status, MycDeliveryOutboxStatus::Failed); - assert_eq!(outbox_records[1].status, MycDeliveryOutboxStatus::Finalized); - - let _ = shutdown_tx.send(()); - listener_task.await??; - - let restarted_runtime = MycRuntime::bootstrap(runtime.config().clone())?; - assert_eq!( - restarted_runtime - .signer_manager()? - .list_connections()? - .len(), - 1 - ); - assert_eq!( - restarted_runtime.operation_audit_store().list_all()?.len(), - 2 - ); - let restarted_outbox = restarted_runtime.delivery_outbox_store().list_all()?; - assert_eq!(restarted_outbox.len(), 2); - assert_eq!(restarted_outbox[0].status, MycDeliveryOutboxStatus::Failed); - assert_eq!( - restarted_outbox[1].status, - MycDeliveryOutboxStatus::Finalized - ); - assert_eq!( - restarted_outbox[0].request_id.as_deref(), - Some("sqlite-connect-1") - ); - assert_eq!( - restarted_outbox[1].request_id.as_deref(), - Some("sqlite-connect-2") - ); - assert!(restarted_outbox[0].signer_publish_workflow_id.is_some()); - assert!(restarted_outbox[1].signer_publish_workflow_id.is_some()); - assert!( - restarted_runtime - .signer_manager()? - .list_publish_workflows()? - .is_empty() - ); - let persisted_connection = restarted_runtime - .signer_manager()? - .list_connections()? - .into_iter() - .next() - .expect("persisted connection"); - assert!(persisted_connection.connect_secret_is_consumed()); - assert_eq!( - persisted_connection - .client_metadata - .as_ref() - .and_then(|metadata| metadata.name.as_deref()), - Some("SQLite Client") - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn external_nostr_client_recovers_connect_response_after_restart() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new(relay.url(), MycConnectionApproval::NotRequired); - let MycTestRuntime { - _temp: _tempdir, - runtime, - } = test_runtime; - let config = runtime.config().clone(); - let signer_public_key = runtime.signer_identity().public_key(); - let user_public_key = runtime.user_identity().public_key(); - let client_identity = - identity("5757575757575757575757575757575757575757575757575757575757575757"); - let base_created_at = Timestamp::now().as_secs(); - let connect_request_id = "external-recovery-connect"; - let connect_request = ExternalNostrConnectRequest::Connect { - remote_signer_public_key: signer_public_key, - secret: None, - }; - let request_message = build_external_request_message(connect_request_id, &connect_request); - let request_event = build_external_request_event( - &client_identity, - signer_public_key, - &request_message, - base_created_at, - ); - publish_event(relay.url(), &request_event).await?; - - let relay_url: RadrootsNostrRelayUrl = relay.url().parse()?; - let manager = runtime.signer_manager()?; - let connection = manager.register_connection( - RadrootsNostrSignerConnectionDraft::new( - client_identity.public_key(), - runtime.user_public_identity(), - ) - .with_relays(vec![relay_url.clone()]) - .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::NotRequired), - )?; - let response_envelope = Response::ConnectAcknowledged.into_envelope(connect_request_id)?; - let response_payload = serde_json::to_string(&response_envelope)?; - let signer_identity = - identity("1111111111111111111111111111111111111111111111111111111111111111"); - let response_ciphertext = nip44::encrypt( - signer_identity.keys().secret_key(), - &client_identity.public_key(), - response_payload, - Version::V2, - )?; - let response_event = runtime.signer_identity().sign_protocol_event_builder( - RadrootsNostrGenericEventBuilder::new( - RadrootsNostrKind::Custom(RPC_KIND), - response_ciphertext, - ) - .tags(vec![RadrootsNostrTag::public_key( - client_identity.public_key(), - )]), - "external recovery queued connect response", - )?; - let queued_record = MycDeliveryOutboxRecord::new( - MycDeliveryOutboxKind::ListenerResponsePublish, - response_event, - vec![relay_url], - )? - .with_connection_id(&connection.connection_id) - .with_request_id(connect_request_id); - runtime.delivery_outbox_store().enqueue(&queued_record)?; - assert_eq!( - queued_record.kind, - MycDeliveryOutboxKind::ListenerResponsePublish - ); - - let restarted_runtime = MycRuntime::bootstrap(config.clone())?; - let persisted_queued_record = restarted_runtime - .delivery_outbox_store() - .list_all()? - .into_iter() - .find(|record| record.request_id.as_deref() == Some(connect_request_id)) - .expect("persisted queued external connect record"); - assert_eq!( - persisted_queued_record.status, - MycDeliveryOutboxStatus::Queued - ); - - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let service_runtime = restarted_runtime.clone(); - let restarted_listener_task = tokio::spawn(async move { - service_runtime - .run_until(async { - let _ = shutdown_rx.await; - }) - .await - }); - - let (_, connect_response) = wait_for_external_response( - &relay, - &client_identity, - signer_public_key, - connect_request_id, - ExternalNostrConnectMethod::Connect, - ) - .await?; - assert_eq!(connect_response.result, Some(ExternalResponseResult::Ack)); - assert_eq!(connect_response.error, None); - - let (_, get_public_key_response) = publish_external_request_and_wait_for_response( - &relay, - &client_identity, - signer_public_key, - "external-recovery-get-public-key", - ExternalNostrConnectRequest::GetPublicKey, - base_created_at + 1, - ) - .await?; - assert_eq!( - get_public_key_response.result, - Some(ExternalResponseResult::GetPublicKey(user_public_key)) - ); - assert_eq!(get_public_key_response.error, None); - - let _ = shutdown_tx.send(()); - restarted_listener_task.await??; - - let finalized_runtime = MycRuntime::bootstrap(config)?; - let finalized_record = finalized_runtime - .delivery_outbox_store() - .list_all()? - .into_iter() - .find(|record| record.request_id.as_deref() == Some(connect_request_id)) - .expect("finalized external connect recovery record"); - assert_eq!(finalized_record.status, MycDeliveryOutboxStatus::Finalized); - assert!(finalized_record.published_at_unix.is_some()); - assert!(finalized_record.finalized_at_unix.is_some()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn startup_recovery_republishes_queued_listener_connect_secret_job() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new_with_transport_relays( - &[relay.url()], - MycConnectionApproval::NotRequired, - ); - let MycTestRuntime { - _temp: _tempdir, - runtime, - } = test_runtime; - let signer_public_key = runtime.signer_identity().public_key(); - let config = runtime.config().clone(); - let client_identity = - identity("5454545454545454545454545454545454545454545454545454545454545454"); - let relay_url: RadrootsNostrRelayUrl = relay.url().parse()?; - - let manager = runtime.signer_manager()?; - let connection = manager.register_connection( - RadrootsNostrSignerConnectionDraft::new( - client_identity.public_key(), - runtime.user_public_identity(), - ) - .with_connect_secret("startup-recovery-secret") - .with_relays(vec![relay_url.clone()]) - .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::NotRequired), - )?; - let workflow = manager.begin_connect_secret_publish_finalization(&connection.connection_id)?; - let event = runtime - .signer_identity() - .sign_protocol_event_builder( - RadrootsNostrGenericEventBuilder::new( - RadrootsNostrKind::Custom(RPC_KIND), - "startup-recovery", - ), - "startup recovery", - ) - .map_err(|error| format!("failed to sign startup recovery event: {error}"))?; - let outbox_record = MycDeliveryOutboxRecord::new( - MycDeliveryOutboxKind::ListenerResponsePublish, - event, - vec![relay_url], - )? - .with_connection_id(&connection.connection_id) - .with_request_id("startup-recovery-connect") - .with_signer_publish_workflow_id(&workflow.workflow_id); - runtime.delivery_outbox_store().enqueue(&outbox_record)?; - - runtime.run_until(async {}).await?; - - let published = relay - .wait_for_published_events_by_author(signer_public_key, 1) - .await?; - assert_eq!(published.len(), 1); - - let restarted_runtime = MycRuntime::bootstrap(config)?; - let recovered_connection = restarted_runtime - .signer_manager()? - .get_connection(&connection.connection_id)? - .expect("persisted connection"); - assert!(recovered_connection.connect_secret_is_consumed()); - assert!( - restarted_runtime - .signer_manager()? - .list_publish_workflows()? - .is_empty() - ); - let outbox_records = restarted_runtime.delivery_outbox_store().list_all()?; - assert_eq!(outbox_records.len(), 1); - assert_eq!(outbox_records[0].status, MycDeliveryOutboxStatus::Finalized); - assert_eq!( - outbox_records[0].request_id.as_deref(), - Some("startup-recovery-connect") - ); - assert!(outbox_records[0].published_at_unix.is_some()); - assert!(outbox_records[0].finalized_at_unix.is_some()); - let audit_records = restarted_runtime.operation_audit_store().list_all()?; - assert_eq!(audit_records.len(), 2); - assert_eq!( - audit_records[0].operation, - MycOperationAuditKind::ListenerResponsePublish - ); - assert_eq!( - audit_records[0].outcome, - MycOperationAuditOutcome::Succeeded - ); - assert_eq!( - audit_records[0].request_id.as_deref(), - Some("startup-recovery-connect") - ); - assert_eq!( - audit_records[1].operation, - MycOperationAuditKind::DeliveryRecovery - ); - assert_eq!( - audit_records[1].outcome, - MycOperationAuditOutcome::Succeeded - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn startup_recovery_republishes_queued_connect_accept_job() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new(relay.url(), MycConnectionApproval::NotRequired); - let MycTestRuntime { - _temp: _tempdir, - runtime, - } = test_runtime; - let signer_public_key = runtime.signer_identity().public_key(); - let config = runtime.config().clone(); - let relay_url: RadrootsNostrRelayUrl = relay.url().parse()?; - let client_identity = - identity("4343434343434343434343434343434343434343434343434343434343434343"); - - let manager = runtime.signer_manager()?; - let connection = manager.register_connection( - RadrootsNostrSignerConnectionDraft::new( - client_identity.public_key(), - runtime.user_public_identity(), - ) - .with_connect_secret("startup-connect-accept-secret") - .with_relays(vec![relay_url.clone()]) - .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::NotRequired), - )?; - let workflow = manager.begin_connect_secret_publish_finalization(&connection.connection_id)?; - let event = runtime - .signer_identity() - .sign_protocol_event_builder( - RadrootsNostrGenericEventBuilder::new( - RadrootsNostrKind::Custom(RPC_KIND), - "startup-recovery-connect-accept", - ), - "startup recovery connect accept", - ) - .map_err(|error| { - format!("failed to sign startup recovery connect-accept event: {error}") - })?; - let outbox_record = MycDeliveryOutboxRecord::new( - MycDeliveryOutboxKind::ConnectAcceptPublish, - event, - vec![relay_url], - )? - .with_connection_id(&connection.connection_id) - .with_request_id("startup-recovery-connect-accept") - .with_signer_publish_workflow_id(&workflow.workflow_id); - runtime.delivery_outbox_store().enqueue(&outbox_record)?; - - runtime.run_until(async {}).await?; - - let published = relay - .wait_for_published_events_by_author(signer_public_key, 1) - .await?; - assert_eq!(published.len(), 1); - - let restarted_runtime = MycRuntime::bootstrap(config)?; - let recovered_connection = restarted_runtime - .signer_manager()? - .get_connection(&connection.connection_id)? - .expect("persisted connection"); - assert!(recovered_connection.connect_secret_is_consumed()); - assert!( - restarted_runtime - .signer_manager()? - .list_publish_workflows()? - .is_empty() - ); - let outbox_records = restarted_runtime.delivery_outbox_store().list_all()?; - assert_eq!(outbox_records.len(), 1); - assert_eq!(outbox_records[0].status, MycDeliveryOutboxStatus::Finalized); - assert_eq!( - outbox_records[0].request_id.as_deref(), - Some("startup-recovery-connect-accept") - ); - assert!(outbox_records[0].published_at_unix.is_some()); - assert!(outbox_records[0].finalized_at_unix.is_some()); - let audit_records = restarted_runtime.operation_audit_store().list_all()?; - assert_eq!(audit_records.len(), 2); - assert_eq!( - audit_records[0].operation, - MycOperationAuditKind::ConnectAcceptPublish - ); - assert_eq!( - audit_records[0].outcome, - MycOperationAuditOutcome::Succeeded - ); - assert_eq!( - audit_records[0].request_id.as_deref(), - Some("startup-recovery-connect-accept") - ); - assert_eq!( - audit_records[1].operation, - MycOperationAuditKind::DeliveryRecovery - ); - assert_eq!( - audit_records[1].outcome, - MycOperationAuditOutcome::Succeeded - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn startup_recovery_republishes_queued_auth_replay_job() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new(relay.url(), MycConnectionApproval::ExplicitUser); - let MycTestRuntime { - _temp: _tempdir, - runtime, - } = test_runtime; - let signer_public_key = runtime.signer_identity().public_key(); - let config = runtime.config().clone(); - let relay_url: RadrootsNostrRelayUrl = relay.url().parse()?; - let client_identity = - identity("5353535353535353535353535353535353535353535353535353535353535353"); - - let manager = runtime.signer_manager()?; - let connection = manager.register_connection( - RadrootsNostrSignerConnectionDraft::new( - client_identity.public_key(), - runtime.user_public_identity(), - ) - .with_relays(vec![relay_url.clone()]) - .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser), - )?; - let _ = manager.require_auth_challenge(&connection.connection_id, "https://auth.example")?; - let _ = manager.set_pending_request( - &connection.connection_id, - ping_request_message("startup-recovery-auth"), - )?; - let workflow = manager.begin_auth_replay_publish_finalization(&connection.connection_id)?; - let event = runtime - .signer_identity() - .sign_protocol_event_builder( - RadrootsNostrGenericEventBuilder::new( - RadrootsNostrKind::Custom(RPC_KIND), - "startup-recovery-auth-replay", - ), - "startup recovery auth replay", - ) - .map_err(|error| format!("failed to sign startup recovery auth-replay event: {error}"))?; - let outbox_record = MycDeliveryOutboxRecord::new( - MycDeliveryOutboxKind::AuthReplayPublish, - event, - vec![relay_url], - )? - .with_connection_id(&connection.connection_id) - .with_request_id("startup-recovery-auth") - .with_signer_publish_workflow_id(&workflow.workflow_id); - runtime.delivery_outbox_store().enqueue(&outbox_record)?; - - runtime.run_until(async {}).await?; - - let published = relay - .wait_for_published_events_by_author(signer_public_key, 1) - .await?; - assert_eq!(published.len(), 1); - - let restarted_runtime = MycRuntime::bootstrap(config)?; - let recovered_connection = restarted_runtime - .signer_manager()? - .get_connection(&connection.connection_id)? - .expect("persisted connection"); - assert_eq!( - recovered_connection.auth_state, - RadrootsNostrSignerAuthState::Authorized - ); - assert!(recovered_connection.pending_request.is_none()); - assert!(recovered_connection.last_authenticated_at_unix.is_some()); - assert!( - restarted_runtime - .signer_manager()? - .list_publish_workflows()? - .is_empty() - ); - let outbox_records = restarted_runtime.delivery_outbox_store().list_all()?; - assert_eq!(outbox_records.len(), 1); - assert_eq!(outbox_records[0].status, MycDeliveryOutboxStatus::Finalized); - assert_eq!( - outbox_records[0].request_id.as_deref(), - Some("startup-recovery-auth") - ); - assert!(outbox_records[0].published_at_unix.is_some()); - assert!(outbox_records[0].finalized_at_unix.is_some()); - let audit_records = restarted_runtime.operation_audit_store().list_all()?; - assert_eq!(audit_records.len(), 2); - assert_eq!( - audit_records[0].operation, - MycOperationAuditKind::AuthReplayPublish - ); - assert_eq!( - audit_records[0].outcome, - MycOperationAuditOutcome::Succeeded - ); - assert_eq!( - audit_records[0].request_id.as_deref(), - Some("startup-recovery-auth") - ); - assert_eq!( - audit_records[1].operation, - MycOperationAuditKind::DeliveryRecovery - ); - assert_eq!( - audit_records[1].outcome, - MycOperationAuditOutcome::Succeeded - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn trusted_client_reauths_after_authorized_ttl() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let client_identity = - identity("7878787878787878787878787878787878787878787878787878787878787878"); - let test_runtime = MycTestRuntime::new_with_transport_config( - &[relay.url()], - MycConnectionApproval::ExplicitUser, - |config| { - config.policy.trusted_client_pubkeys = vec![client_identity.public_key().to_hex()]; - config.policy.permission_ceiling = "sign_event:1".parse().expect("permission ceiling"); - config.policy.allowed_sign_event_kinds = vec![1]; - config.policy.auth_url = Some("https://auth.example/challenge".to_owned()); - config.policy.auth_authorized_ttl_secs = Some(1); - }, - ); - let runtime = test_runtime.runtime.clone(); - let signer_public_key = runtime.signer_identity().public_key(); - - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let service_runtime = runtime.clone(); - let listener_task = tokio::spawn(async move { - service_runtime - .run_until(async { - let _ = shutdown_rx.await; - }) - .await - }); - - relay.wait_for_subscription_count(1).await?; - - let connect_request = build_request_event( - &client_identity, - signer_public_key, - RequestMessage::new( - "trusted-connect", - Request::Connect { - remote_signer_public_key: connect_public_key(signer_public_key), - secret: None, - requested_permissions: "sign_event:1".parse().expect("requested permissions"), - client_metadata: None, - }, - ), - Timestamp::now().as_secs(), - ); - publish_event(relay.url(), &connect_request).await?; - let response_events = relay - .wait_for_published_events_by_author(signer_public_key, 1) - .await?; - let connect_response = - decrypt_response(&client_identity, signer_public_key, &response_events[0]); - let connect_parsed = radroots_nostr_connect::Response::from_envelope( - &Request::Connect { - remote_signer_public_key: connect_public_key(signer_public_key), - secret: None, - requested_permissions: "sign_event:1".parse().expect("requested permissions"), - client_metadata: None, - } - .method(), - connect_response, - )?; - assert_eq!( - connect_parsed, - radroots_nostr_connect::Response::ConnectAcknowledged - ); - - let sign_request = |request_id: &str, created_at_unix| { - build_request_event( - &client_identity, - signer_public_key, - RequestMessage::new( - request_id, - Request::SignEvent(connect_unsigned_event( - runtime.user_identity().public_key(), - created_at_unix, - 1, - request_id, - )), - ), - created_at_unix, - ) - }; - - publish_event( - relay.url(), - &sign_request("trusted-sign-1", Timestamp::now().as_secs()), - ) - .await?; - let response_events = relay - .wait_for_published_events_by_author(signer_public_key, 2) - .await?; - let first_auth = decrypt_response(&client_identity, signer_public_key, &response_events[1]); - let first_auth = radroots_nostr_connect::Response::from_envelope( - &Request::SignEvent(connect_unsigned_event( - runtime.user_identity().public_key(), - Timestamp::from(1).as_secs(), - 1, - "trusted-sign-1", - )) - .method(), - first_auth, - )?; - assert_eq!( - first_auth, - radroots_nostr_connect::Response::AuthUrl("https://auth.example/challenge".to_owned()) - ); - - let connection = runtime - .signer_manager()? - .list_connections()? - .into_iter() - .next() - .expect("connection"); - let replayed = control::authorize_auth_challenge(&runtime, &connection.connection_id).await?; - assert_eq!( - replayed.replayed_request_id.as_deref(), - Some("trusted-sign-1") - ); - - let response_events = relay - .wait_for_published_events_by_author(signer_public_key, 3) - .await?; - let replay_response = - decrypt_response(&client_identity, signer_public_key, &response_events[2]); - let replay_parsed = radroots_nostr_connect::Response::from_envelope( - &Request::SignEvent(connect_unsigned_event( - runtime.user_identity().public_key(), - Timestamp::from(1).as_secs(), - 1, - "trusted-sign-1", - )) - .method(), - replay_response, - )?; - assert!(matches!( - replay_parsed, - radroots_nostr_connect::Response::SignedEvent(_) - )); - - sleep(Duration::from_secs(2)).await; - - publish_event( - relay.url(), - &sign_request("trusted-sign-2", Timestamp::now().as_secs()), - ) - .await?; - let response_events = relay - .wait_for_published_events_by_author(signer_public_key, 4) - .await?; - let second_auth = decrypt_response(&client_identity, signer_public_key, &response_events[3]); - let second_auth = radroots_nostr_connect::Response::from_envelope( - &Request::SignEvent(connect_unsigned_event( - runtime.user_identity().public_key(), - Timestamp::from(1).as_secs(), - 1, - "trusted-sign-2", - )) - .method(), - second_auth, - )?; - assert_eq!( - second_auth, - radroots_nostr_connect::Response::AuthUrl("https://auth.example/challenge".to_owned()) - ); - - let _ = shutdown_tx.send(()); - listener_task.await??; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn connect_accept_retries_without_consuming_secret_until_publish_succeeds() -> TestResult<()> -{ - let relay = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new(relay.url(), MycConnectionApproval::NotRequired); - let runtime = test_runtime.runtime; - let signer_public_key = runtime.signer_identity().public_key(); - let client_identity = - identity("4444444444444444444444444444444444444444444444444444444444444444"); - - relay - .queue_publish_outcomes(signer_public_key, &[false, true]) - .await; - - let client_metadata = ClientMetadata { - requested_permissions: Default::default(), - name: Some(" Connect Accept Client ".to_owned()), - url: Some("https://connect.example/".to_owned()), - image: Some("https://connect.example/icon.png".to_owned()), - }; - let client_uri = connect_client_uri( - &client_identity, - &[relay.url()], - "client-secret", - &client_metadata, - )?; - - let failed = control::accept_client_uri(&runtime, &client_uri) - .await - .expect_err("first publish should fail"); - assert!(failed.to_string().contains("Nostr publish failed")); - - let stored_after_failure = runtime - .signer_manager()? - .list_connections()? - .into_iter() - .next() - .expect("stored connection"); - assert!(!stored_after_failure.connect_secret_is_consumed()); - assert_eq!( - stored_after_failure - .client_metadata - .as_ref() - .and_then(|metadata| metadata.name.as_deref()), - Some("Connect Accept Client") - ); - let operation_audit = wait_for_operation_audit_count(&runtime, 1).await?; - assert_eq!( - operation_audit[0].operation, - MycOperationAuditKind::ConnectAcceptPublish - ); - assert_eq!( - operation_audit[0].outcome, - MycOperationAuditOutcome::Rejected - ); - assert_eq!( - operation_audit[0].connection_id.as_deref(), - Some(stored_after_failure.connection_id.as_str()) - ); - assert!(operation_audit[0].request_id.is_some()); - assert_eq!(operation_audit[0].relay_count, 1); - assert_eq!(operation_audit[0].acknowledged_relay_count, 0); - assert!( - operation_audit[0] - .relay_outcome_summary - .contains("blocked by test relay") - ); - let outbox_records = wait_for_delivery_outbox_records(&runtime, |records| { - !records.is_empty() && records[0].status == MycDeliveryOutboxStatus::Failed - }) - .await?; - assert_eq!( - outbox_records[0].kind, - MycDeliveryOutboxKind::ConnectAcceptPublish - ); - assert_eq!( - outbox_records[0].request_id.as_deref(), - operation_audit[0].request_id.as_deref() - ); - assert!(outbox_records[0].signer_publish_workflow_id.is_some()); - assert!( - runtime - .signer_manager()? - .list_publish_workflows()? - .is_empty() - ); - - let accepted = control::accept_client_uri(&runtime, &client_uri).await?; - assert_eq!(accepted.response_request_id.len(), 36); - - let response_events = relay - .wait_for_published_events_by_author(signer_public_key, 1) - .await?; - let response = decrypt_response(&client_identity, signer_public_key, &response_events[0]); - assert_eq!(response.id, accepted.response_request_id); - assert_eq!( - response.result, - Some(serde_json::Value::String("client-secret".to_owned())) - ); - - let stored_after_success = runtime - .signer_manager()? - .list_connections()? - .into_iter() - .next() - .expect("stored connection"); - assert!(stored_after_success.connect_secret_is_consumed()); - let operation_audit = wait_for_operation_audit_count(&runtime, 2).await?; - assert_eq!( - operation_audit[1].operation, - MycOperationAuditKind::ConnectAcceptPublish - ); - assert_eq!( - operation_audit[1].outcome, - MycOperationAuditOutcome::Succeeded - ); - assert_eq!( - operation_audit[1].connection_id.as_deref(), - Some(stored_after_success.connection_id.as_str()) - ); - assert_eq!( - operation_audit[1].request_id.as_deref(), - Some(accepted.response_request_id.as_str()) - ); - assert_eq!(operation_audit[1].relay_count, 1); - assert_eq!(operation_audit[1].acknowledged_relay_count, 1); - assert!( - operation_audit[1] - .relay_outcome_summary - .contains("1/1 relays acknowledged publish") - ); - let outbox_records = wait_for_delivery_outbox_records(&runtime, |records| { - records.len() >= 2 && records[1].status == MycDeliveryOutboxStatus::Finalized - }) - .await?; - assert_eq!( - outbox_records[1].kind, - MycDeliveryOutboxKind::ConnectAcceptPublish - ); - assert_eq!( - outbox_records[1].request_id.as_deref(), - Some(accepted.response_request_id.as_str()) - ); - assert!(outbox_records[1].published_at_unix.is_some()); - assert!(outbox_records[1].finalized_at_unix.is_some()); - assert!(outbox_records[1].signer_publish_workflow_id.is_some()); - assert!( - runtime - .signer_manager()? - .list_publish_workflows()? - .is_empty() - ); - - let consumed = control::accept_client_uri(&runtime, &client_uri) - .await - .expect_err("consumed secret should be rejected"); - assert!( - consumed - .to_string() - .contains("connect secret has already been consumed") - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn connect_accept_succeeds_with_any_delivery_policy_when_one_relay_acknowledges() --> TestResult<()> { - let relay_a = TestRelay::spawn().await?; - let relay_b = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new_with_transport_config( - &[relay_a.url(), relay_b.url()], - MycConnectionApproval::NotRequired, - |config| { - config.transport.delivery_policy = MycTransportDeliveryPolicy::Any; - config.transport.publish_max_attempts = 1; - }, - ); - let runtime = test_runtime.runtime; - let signer_public_key = runtime.signer_identity().public_key(); - let client_identity = - identity("5555555555555555555555555555555555555555555555555555555555555555"); - - relay_a - .queue_publish_outcomes(signer_public_key, &[false]) - .await; - relay_b - .queue_publish_outcomes(signer_public_key, &[true]) - .await; - - let client_uri = connect_client_uri( - &client_identity, - &[relay_a.url(), relay_b.url()], - "delivery-any-secret", - &ClientMetadata::default(), - )?; - - let accepted = control::accept_client_uri(&runtime, &client_uri).await?; - assert_eq!(accepted.response_relays.len(), 2); - let stored = runtime - .signer_manager()? - .list_connections()? - .into_iter() - .find(|connection| connection.connection_id == accepted.connection.connection_id) - .expect("stored connection"); - assert!(stored.connect_secret_is_consumed()); - - let operation_audit = wait_for_operation_audit_count(&runtime, 1).await?; - assert_eq!( - operation_audit[0].operation, - MycOperationAuditKind::ConnectAcceptPublish - ); - assert_eq!( - operation_audit[0].outcome, - MycOperationAuditOutcome::Succeeded - ); - assert_eq!(operation_audit[0].relay_count, 2); - assert_eq!(operation_audit[0].acknowledged_relay_count, 1); - assert_eq!( - operation_audit[0].delivery_policy, - Some(MycTransportDeliveryPolicy::Any) - ); - assert_eq!( - operation_audit[0].required_acknowledged_relay_count, - Some(1) - ); - assert_eq!(operation_audit[0].publish_attempt_count, Some(1)); - assert!( - operation_audit[0] - .relay_outcome_summary - .contains("delivery policy any") - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn connect_accept_rejects_when_quorum_delivery_policy_is_not_met() -> TestResult<()> { - let relay_a = TestRelay::spawn().await?; - let relay_b = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new_with_transport_config( - &[relay_a.url(), relay_b.url()], - MycConnectionApproval::NotRequired, - |config| { - config.transport.delivery_policy = MycTransportDeliveryPolicy::Quorum; - config.transport.delivery_quorum = Some(2); - config.transport.publish_max_attempts = 1; - }, - ); - let runtime = test_runtime.runtime; - let signer_public_key = runtime.signer_identity().public_key(); - let client_identity = - identity("6666666666666666666666666666666666666666666666666666666666666665"); - - relay_a - .queue_publish_outcomes(signer_public_key, &[true]) - .await; - relay_b - .queue_publish_outcomes(signer_public_key, &[false]) - .await; - - let client_uri = connect_client_uri( - &client_identity, - &[relay_a.url(), relay_b.url()], - "delivery-quorum-secret", - &ClientMetadata::default(), - )?; - - let error = control::accept_client_uri(&runtime, &client_uri) - .await - .expect_err("quorum publish should fail"); - assert!( - error - .to_string() - .contains("delivery policy quorum requiring 2 acknowledgements") - ); - assert_eq!( - error.publish_delivery_policy(), - Some(MycTransportDeliveryPolicy::Quorum) - ); - assert_eq!(error.publish_required_acknowledged_relay_count(), Some(2)); - assert_eq!(error.publish_attempt_count(), Some(1)); - - let stored = runtime - .signer_manager()? - .list_connections()? - .into_iter() - .next() - .expect("stored connection"); - assert!(!stored.connect_secret_is_consumed()); - - let operation_audit = wait_for_operation_audit_count(&runtime, 1).await?; - assert_eq!( - operation_audit[0].operation, - MycOperationAuditKind::ConnectAcceptPublish - ); - assert_eq!( - operation_audit[0].outcome, - MycOperationAuditOutcome::Rejected - ); - assert_eq!(operation_audit[0].relay_count, 2); - assert_eq!(operation_audit[0].acknowledged_relay_count, 1); - assert_eq!( - operation_audit[0].delivery_policy, - Some(MycTransportDeliveryPolicy::Quorum) - ); - assert_eq!( - operation_audit[0].required_acknowledged_relay_count, - Some(2) - ); - assert_eq!(operation_audit[0].publish_attempt_count, Some(1)); - let outbox_records = wait_for_delivery_outbox_records(&runtime, |records| { - !records.is_empty() && records[0].status == MycDeliveryOutboxStatus::Failed - }) - .await?; - assert_eq!( - outbox_records[0].kind, - MycDeliveryOutboxKind::ConnectAcceptPublish - ); - assert!(outbox_records[0].signer_publish_workflow_id.is_some()); - assert!( - runtime - .signer_manager()? - .list_publish_workflows()? - .is_empty() - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn live_listener_retries_until_all_delivery_policy_is_met() -> TestResult<()> { - let relay_a = TestRelay::spawn().await?; - let relay_b = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new_with_transport_config( - &[relay_a.url(), relay_b.url()], - MycConnectionApproval::NotRequired, - |config| { - config.transport.delivery_policy = MycTransportDeliveryPolicy::All; - config.transport.publish_max_attempts = 2; - config.transport.publish_initial_backoff_millis = 10; - config.transport.publish_max_backoff_millis = 10; - }, - ); - let runtime = test_runtime.runtime.clone(); - let signer_public_key = runtime.signer_identity().public_key(); - let client_identity = - identity("7777777777777777777777777777777777777777777777777777777777777777"); - let base_created_at = Timestamp::now().as_secs(); - - relay_a - .queue_publish_outcomes(signer_public_key, &[true, true]) - .await; - relay_b - .queue_publish_outcomes(signer_public_key, &[false, true]) - .await; - - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let service_runtime = runtime.clone(); - let listener_task = tokio::spawn(async move { - service_runtime - .run_until(async { - let _ = shutdown_rx.await; - }) - .await - }); - - relay_a.wait_for_subscription_count(1).await?; - relay_b.wait_for_subscription_count(1).await?; - - let request = build_request_event( - &client_identity, - signer_public_key, - connect_request_message("connect-all-1", signer_public_key, "shared-secret-all"), - base_created_at, - ); - publish_event(relay_a.url(), &request).await?; - - let response_events = relay_b - .wait_for_published_events_by_author(signer_public_key, 1) - .await?; - let response = decrypt_response(&client_identity, signer_public_key, &response_events[0]); - assert_eq!(response.id, "connect-all-1"); - assert_eq!( - response.result, - Some(serde_json::Value::String("shared-secret-all".to_owned())) - ); - - wait_for_connect_secret_consumed(&runtime).await?; - let operation_audit = wait_for_operation_audit_count(&runtime, 1).await?; - assert_eq!( - operation_audit[0].operation, - MycOperationAuditKind::ListenerResponsePublish - ); - assert_eq!( - operation_audit[0].outcome, - MycOperationAuditOutcome::Succeeded - ); - assert_eq!(operation_audit[0].relay_count, 2); - assert_eq!(operation_audit[0].acknowledged_relay_count, 2); - assert_eq!( - operation_audit[0].delivery_policy, - Some(MycTransportDeliveryPolicy::All) - ); - assert_eq!( - operation_audit[0].required_acknowledged_relay_count, - Some(2) - ); - assert_eq!(operation_audit[0].publish_attempt_count, Some(2)); - assert!( - operation_audit[0] - .relay_outcome_summary - .contains("attempt 1: 1/2 relays acknowledged publish") - ); - - let _ = shutdown_tx.send(()); - listener_task.await??; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn auth_replay_restores_pending_request_until_publish_succeeds() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new(relay.url(), MycConnectionApproval::NotRequired); - let runtime = test_runtime.runtime; - let signer_public_key = runtime.signer_identity().public_key(); - let client_public_key = Keys::new(SecretKey::from_hex( - "5555555555555555555555555555555555555555555555555555555555555555", - )?) - .public_key(); - - relay - .queue_publish_outcomes(signer_public_key, &[false, true]) - .await; - - let manager = runtime.signer_manager()?; - let connection = manager.register_connection( - RadrootsNostrSignerConnectionDraft::new(client_public_key, runtime.user_public_identity()) - .with_relays(vec![nostr::RelayUrl::parse(relay.url())?]) - .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::NotRequired), - )?; - manager.require_auth_challenge(&connection.connection_id, "https://auth.example/flow")?; - manager.set_pending_request(&connection.connection_id, ping_request_message("auth-ping"))?; - - let first_attempt = control::authorize_auth_challenge(&runtime, &connection.connection_id) - .await - .expect_err("first replay publish should fail"); - assert!(first_attempt.to_string().contains("Nostr publish failed")); - - let restored = runtime - .signer_manager()? - .get_connection(&connection.connection_id)? - .expect("restored connection"); - assert_eq!(restored.auth_state, RadrootsNostrSignerAuthState::Pending); - assert_eq!( - restored - .pending_request - .as_ref() - .expect("pending request") - .request_id() - .as_str(), - "auth-ping" - ); - assert_eq!( - restored - .auth_challenge - .as_ref() - .expect("auth challenge") - .authorized_at_unix, - None - ); - let operation_audit = wait_for_operation_audit_count(&runtime, 2).await?; - assert_eq!( - operation_audit[0].operation, - MycOperationAuditKind::AuthReplayPublish - ); - assert_eq!( - operation_audit[0].outcome, - MycOperationAuditOutcome::Rejected - ); - assert_eq!( - operation_audit[0].connection_id.as_deref(), - Some(connection.connection_id.as_str()) - ); - assert_eq!(operation_audit[0].request_id.as_deref(), Some("auth-ping")); - assert_eq!(operation_audit[0].relay_count, 1); - assert_eq!(operation_audit[0].acknowledged_relay_count, 0); - assert!( - operation_audit[0] - .relay_outcome_summary - .contains("blocked by test relay") - ); - assert_eq!( - operation_audit[1].operation, - MycOperationAuditKind::AuthReplayRestore - ); - assert_eq!( - operation_audit[1].outcome, - MycOperationAuditOutcome::Restored - ); - assert_eq!( - operation_audit[1].connection_id.as_deref(), - Some(connection.connection_id.as_str()) - ); - assert_eq!(operation_audit[1].request_id.as_deref(), Some("auth-ping")); - assert_eq!(operation_audit[1].relay_count, 1); - assert_eq!(operation_audit[1].acknowledged_relay_count, 0); - assert!( - operation_audit[1] - .relay_outcome_summary - .contains("preserved pending auth challenge") - ); - let outbox_records = wait_for_delivery_outbox_records(&runtime, |records| { - !records.is_empty() && records[0].status == MycDeliveryOutboxStatus::Failed - }) - .await?; - assert_eq!( - outbox_records[0].kind, - MycDeliveryOutboxKind::AuthReplayPublish - ); - assert_eq!(outbox_records[0].request_id.as_deref(), Some("auth-ping")); - assert!(outbox_records[0].signer_publish_workflow_id.is_some()); - assert!( - runtime - .signer_manager()? - .list_publish_workflows()? - .is_empty() - ); - - let replayed = control::authorize_auth_challenge(&runtime, &connection.connection_id).await?; - assert_eq!(replayed.replayed_request_id.as_deref(), Some("auth-ping")); - - let client_identity = - identity("5555555555555555555555555555555555555555555555555555555555555555"); - let response_events = relay - .wait_for_published_events_by_author(signer_public_key, 1) - .await?; - let response = decrypt_response(&client_identity, signer_public_key, &response_events[0]); - assert_eq!(response.id, "auth-ping"); - assert_eq!( - response.result, - Some(serde_json::Value::String("pong".to_owned())) - ); - - let authorized = runtime - .signer_manager()? - .get_connection(&connection.connection_id)? - .expect("authorized connection"); - assert_eq!( - authorized.auth_state, - RadrootsNostrSignerAuthState::Authorized - ); - assert!(authorized.pending_request.is_none()); - assert!(authorized.last_authenticated_at_unix.is_some()); - let operation_audit = wait_for_operation_audit_count(&runtime, 3).await?; - assert_eq!( - operation_audit[2].operation, - MycOperationAuditKind::AuthReplayPublish - ); - assert_eq!( - operation_audit[2].outcome, - MycOperationAuditOutcome::Succeeded - ); - assert_eq!( - operation_audit[2].connection_id.as_deref(), - Some(connection.connection_id.as_str()) - ); - assert_eq!(operation_audit[2].request_id.as_deref(), Some("auth-ping")); - assert_eq!(operation_audit[2].relay_count, 1); - assert_eq!(operation_audit[2].acknowledged_relay_count, 1); - assert!( - operation_audit[2] - .relay_outcome_summary - .contains("1/1 relays acknowledged publish") - ); - let outbox_records = wait_for_delivery_outbox_records(&runtime, |records| { - records.len() >= 2 && records[1].status == MycDeliveryOutboxStatus::Finalized - }) - .await?; - assert_eq!( - outbox_records[1].kind, - MycDeliveryOutboxKind::AuthReplayPublish - ); - assert_eq!(outbox_records[1].request_id.as_deref(), Some("auth-ping")); - assert!(outbox_records[1].published_at_unix.is_some()); - assert!(outbox_records[1].finalized_at_unix.is_some()); - assert!(outbox_records[1].signer_publish_workflow_id.is_some()); - assert!( - runtime - .signer_manager()? - .list_publish_workflows()? - .is_empty() - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn explicit_nip89_publish_uses_app_identity_and_records_audit() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = - MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); - let runtime = test_runtime.runtime; - let app_identity = myc::identity_files::load_encrypted_identity( - runtime - .config() - .discovery - .app_identity_path - .as_ref() - .expect("app identity path"), - )?; - - relay - .queue_publish_outcomes(app_identity.public_key(), &[true]) - .await; - - let published = publish_nip89_event(&runtime).await?; - let published_event_id = published.event.id.to_hex(); - let published_events = relay - .wait_for_published_events_by_author(app_identity.public_key(), 1) - .await?; - let event = &published_events[0]; - let event_json = event.as_json(); - - assert_eq!( - published.author_public_key_hex, - app_identity.public_key_hex() - ); - assert_eq!( - published.signer_public_key_hex, - runtime.signer_identity().public_key_hex() - ); - assert_eq!(event.kind.as_u16(), 31_990); - assert!(event_json.contains("\"24133\"")); - assert!(event_json.contains("\"relay\"")); - assert!(event_json.contains("\"nostrconnect_url\"")); - assert_eq!(published.relay_count, 1); - assert_eq!(published.acknowledged_relay_count, 1); - - let operation_audit = wait_for_operation_audit_count(&runtime, 1).await?; - assert_eq!( - operation_audit[0].operation, - MycOperationAuditKind::DiscoveryHandlerPublish - ); - assert_eq!( - operation_audit[0].outcome, - MycOperationAuditOutcome::Succeeded - ); - assert!(operation_audit[0].connection_id.is_none()); - assert_eq!( - operation_audit[0].request_id.as_deref(), - Some(published_event_id.as_str()) - ); - assert_eq!(operation_audit[0].relay_count, 1); - assert_eq!(operation_audit[0].acknowledged_relay_count, 1); - assert!( - operation_audit[0] - .relay_outcome_summary - .contains("1/1 relays acknowledged publish") - ); - let outbox_records = wait_for_delivery_outbox_records(&runtime, |records| { - !records.is_empty() && records[0].status == MycDeliveryOutboxStatus::Finalized - }) - .await?; - assert_eq!( - outbox_records[0].kind, - MycDeliveryOutboxKind::DiscoveryHandlerPublish - ); - assert_eq!( - outbox_records[0].request_id.as_deref(), - Some(published_event_id.as_str()) - ); - assert!(outbox_records[0].attempt_id.is_none()); - assert!(outbox_records[0].signer_publish_workflow_id.is_none()); - assert!(outbox_records[0].published_at_unix.is_some()); - assert!(outbox_records[0].finalized_at_unix.is_some()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn startup_recovery_republishes_queued_discovery_publish_job() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = - MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); - let MycTestRuntime { - _temp: _tempdir, - runtime, - } = test_runtime; - let config = runtime.config().clone(); - let relay_url: RadrootsNostrRelayUrl = relay.url().parse()?; - let context = MycDiscoveryContext::from_runtime(&runtime)?; - let app_public_key = context.app_identity().public_key(); - let event = context.build_signed_handler_event()?; - let event_id = event.id.to_hex(); - let outbox_record = MycDeliveryOutboxRecord::new( - MycDeliveryOutboxKind::DiscoveryHandlerPublish, - event, - vec![relay_url], - )? - .with_request_id(event_id.as_str()); - runtime.delivery_outbox_store().enqueue(&outbox_record)?; - - runtime.run_until(async {}).await?; - - let published = relay - .wait_for_published_events_by_author(app_public_key, 1) - .await?; - assert_eq!(published.len(), 1); - assert_eq!(published[0].id.to_hex(), event_id); - - let restarted_runtime = MycRuntime::bootstrap(config)?; - let outbox_records = restarted_runtime.delivery_outbox_store().list_all()?; - assert_eq!(outbox_records.len(), 1); - assert_eq!(outbox_records[0].status, MycDeliveryOutboxStatus::Finalized); - assert_eq!( - outbox_records[0].request_id.as_deref(), - Some(event_id.as_str()) - ); - assert!(outbox_records[0].published_at_unix.is_some()); - assert!(outbox_records[0].finalized_at_unix.is_some()); - let audit_records = restarted_runtime.operation_audit_store().list_all()?; - assert_eq!(audit_records.len(), 2); - assert_eq!( - audit_records[0].operation, - MycOperationAuditKind::DiscoveryHandlerPublish - ); - assert_eq!( - audit_records[0].outcome, - MycOperationAuditOutcome::Succeeded - ); - assert_eq!( - audit_records[0].request_id.as_deref(), - Some(event_id.as_str()) - ); - assert_eq!( - audit_records[1].operation, - MycOperationAuditKind::DeliveryRecovery - ); - assert_eq!( - audit_records[1].outcome, - MycOperationAuditOutcome::Succeeded - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn startup_recovery_finalizes_published_discovery_publish_job() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = - MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); - let MycTestRuntime { - _temp: _tempdir, - runtime, - } = test_runtime; - let config = runtime.config().clone(); - let relay_url: RadrootsNostrRelayUrl = relay.url().parse()?; - let context = MycDiscoveryContext::from_runtime(&runtime)?; - let app_public_key = context.app_identity().public_key(); - let event = context.build_signed_handler_event()?; - let event_id = event.id.to_hex(); - let outbox_record = MycDeliveryOutboxRecord::new( - MycDeliveryOutboxKind::DiscoveryHandlerPublish, - event, - vec![relay_url], - )? - .with_request_id(event_id.as_str()); - runtime.delivery_outbox_store().enqueue(&outbox_record)?; - runtime - .delivery_outbox_store() - .mark_published_pending_finalize(&outbox_record.job_id, 1)?; - - runtime.run_until(async {}).await?; - - sleep(Duration::from_millis(100)).await; - assert!( - relay - .published_events_by_author(app_public_key) - .await - .is_empty() - ); - - let restarted_runtime = MycRuntime::bootstrap(config)?; - let outbox_records = restarted_runtime.delivery_outbox_store().list_all()?; - assert_eq!(outbox_records.len(), 1); - assert_eq!(outbox_records[0].status, MycDeliveryOutboxStatus::Finalized); - assert_eq!( - outbox_records[0].request_id.as_deref(), - Some(event_id.as_str()) - ); - assert!(outbox_records[0].published_at_unix.is_some()); - assert!(outbox_records[0].finalized_at_unix.is_some()); - let audit_records = restarted_runtime.operation_audit_store().list_all()?; - assert_eq!(audit_records.len(), 2); - assert_eq!( - audit_records[0].operation, - MycOperationAuditKind::DiscoveryHandlerPublish - ); - assert_eq!( - audit_records[0].outcome, - MycOperationAuditOutcome::Succeeded - ); - assert_eq!( - audit_records[0].request_id.as_deref(), - Some(event_id.as_str()) - ); - assert!( - audit_records[0] - .relay_outcome_summary - .contains("startup recovery finalized previously published delivery job") - ); - assert_eq!( - audit_records[1].operation, - MycOperationAuditKind::DeliveryRecovery - ); - assert_eq!( - audit_records[1].outcome, - MycOperationAuditOutcome::Succeeded - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn explicit_nip89_publish_retries_cleanly_after_rejection() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = - MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); - let runtime = test_runtime.runtime; - let app_identity = myc::identity_files::load_encrypted_identity( - runtime - .config() - .discovery - .app_identity_path - .as_ref() - .expect("app identity path"), - )?; - - relay - .queue_publish_outcomes(app_identity.public_key(), &[false, true]) - .await; - - let failed = publish_nip89_event(&runtime) - .await - .expect_err("first publish should fail"); - assert!(failed.to_string().contains("Nostr publish failed")); - assert!( - relay - .published_events_by_author(app_identity.public_key()) - .await - .is_empty() - ); - - let first_audit = wait_for_operation_audit_count(&runtime, 1).await?; - assert_eq!( - first_audit[0].operation, - MycOperationAuditKind::DiscoveryHandlerPublish - ); - assert_eq!(first_audit[0].outcome, MycOperationAuditOutcome::Rejected); - assert!(first_audit[0].connection_id.is_none()); - assert!(first_audit[0].request_id.is_some()); - assert_eq!(first_audit[0].relay_count, 1); - assert_eq!(first_audit[0].acknowledged_relay_count, 0); - assert!( - first_audit[0] - .relay_outcome_summary - .contains("blocked by test relay") - ); - let outbox_records = wait_for_delivery_outbox_records(&runtime, |records| { - !records.is_empty() && records[0].status == MycDeliveryOutboxStatus::Failed - }) - .await?; - assert_eq!( - outbox_records[0].kind, - MycDeliveryOutboxKind::DiscoveryHandlerPublish - ); - assert_eq!( - outbox_records[0].request_id.as_deref(), - first_audit[0].request_id.as_deref() - ); - assert!(outbox_records[0].attempt_id.is_none()); - assert!(outbox_records[0].signer_publish_workflow_id.is_none()); - - let published = publish_nip89_event(&runtime).await?; - let published_events = relay - .wait_for_published_events_by_author(app_identity.public_key(), 1) - .await?; - assert_eq!(published_events.len(), 1); - assert_eq!(published.relay_count, 1); - assert_eq!(published.acknowledged_relay_count, 1); - - let second_audit = wait_for_operation_audit_count(&runtime, 2).await?; - assert_eq!( - second_audit[1].operation, - MycOperationAuditKind::DiscoveryHandlerPublish - ); - assert_eq!(second_audit[1].outcome, MycOperationAuditOutcome::Succeeded); - assert_eq!( - second_audit[1].request_id.as_deref(), - Some(published.event.id.to_hex().as_str()) - ); - assert_eq!(second_audit[1].relay_count, 1); - assert_eq!(second_audit[1].acknowledged_relay_count, 1); - assert!( - second_audit[1] - .relay_outcome_summary - .contains("1/1 relays acknowledged publish") - ); - let outbox_records = wait_for_delivery_outbox_records(&runtime, |records| { - records.len() >= 2 && records[1].status == MycDeliveryOutboxStatus::Finalized - }) - .await?; - assert_eq!(outbox_records[0].status, MycDeliveryOutboxStatus::Failed); - assert_eq!( - outbox_records[1].kind, - MycDeliveryOutboxKind::DiscoveryHandlerPublish - ); - assert_eq!( - outbox_records[1].request_id.as_deref(), - Some(published.event.id.to_hex().as_str()) - ); - assert!(outbox_records[1].attempt_id.is_none()); - assert!(outbox_records[1].signer_publish_workflow_id.is_none()); - assert!(outbox_records[1].published_at_unix.is_some()); - assert!(outbox_records[1].finalized_at_unix.is_some()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn fetch_live_nip89_reports_missing_when_handler_is_unpublished() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = - MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); - - let output = fetch_live_nip89(&test_runtime.runtime).await?; - - assert_eq!(output.handler_identifier, "myc"); - assert_eq!(output.publish_relays, vec![relay.url().to_owned()]); - assert!(output.live_groups.is_empty()); - assert_eq!(output.relay_states.len(), 1); - assert_eq!( - output.relay_states[0].fetch_status, - MycDiscoveryRelayFetchStatus::Available - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn fetch_live_nip89_fails_when_all_discovery_relays_are_unavailable() -> TestResult<()> { - let unavailable_a = unavailable_relay_url()?; - let unavailable_b = unavailable_relay_url()?; - let test_runtime = MycTestRuntime::new_with_discovery_relays( - &[unavailable_a.as_str(), unavailable_b.as_str()], - MycConnectionApproval::ExplicitUser, - ); - - let error = fetch_live_nip89(&test_runtime.runtime) - .await - .expect_err("all-unavailable discovery fetch should fail"); - assert!( - error - .to_string() - .contains("failed to fetch discovery state from all configured relays") - ); - - let audit = wait_for_operation_audit_count(&test_runtime.runtime, 1).await?; - assert_eq!( - audit[0].operation, - MycOperationAuditKind::DiscoveryHandlerFetch - ); - assert_eq!(audit[0].outcome, MycOperationAuditOutcome::Unavailable); - assert_eq!(audit[0].relay_count, 2); - assert_eq!(audit[0].acknowledged_relay_count, 0); - assert!(audit[0].relay_outcome_summary.contains(&unavailable_a)); - assert!(audit[0].relay_outcome_summary.contains(&unavailable_b)); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn fetch_live_nip89_parallelizes_relay_fetch_and_preserves_configured_order() -> TestResult<()> -{ - let live_relay = TestRelay::spawn().await?; - let slow_a = HangingRelay::spawn(Duration::from_secs(3)).await?; - let slow_b = HangingRelay::spawn(Duration::from_secs(3)).await?; - let slow_c = HangingRelay::spawn(Duration::from_secs(3)).await?; - let slow_d = HangingRelay::spawn(Duration::from_secs(3)).await?; - let relay_urls = [ - slow_a.url(), - live_relay.url(), - slow_b.url(), - slow_c.url(), - slow_d.url(), - ]; - let mut expected_relay_states = [ - ( - slow_a.url().to_owned(), - MycDiscoveryRelayFetchStatus::Unavailable, - ), - ( - live_relay.url().to_owned(), - MycDiscoveryRelayFetchStatus::Available, - ), - ( - slow_b.url().to_owned(), - MycDiscoveryRelayFetchStatus::Unavailable, - ), - ( - slow_c.url().to_owned(), - MycDiscoveryRelayFetchStatus::Unavailable, - ), - ( - slow_d.url().to_owned(), - MycDiscoveryRelayFetchStatus::Unavailable, - ), - ]; - expected_relay_states.sort_by(|left, right| left.0.cmp(&right.0)); - let expected_relay_urls = expected_relay_states - .iter() - .map(|(relay_url, _)| relay_url.clone()) - .collect::<Vec<_>>(); - let test_runtime = MycTestRuntime::new_with_discovery_relays_and_timeout( - &relay_urls, - MycConnectionApproval::ExplicitUser, - 1, - ); - - let started_at = Instant::now(); - let output = fetch_live_nip89(&test_runtime.runtime).await?; - let elapsed = started_at.elapsed(); - - assert!( - elapsed < Duration::from_millis(2500), - "expected concurrent relay fetch to finish under 2.5s, got {:?}", - elapsed - ); - assert_eq!( - output - .relay_states - .iter() - .map(|relay_state| relay_state.relay_url.clone()) - .collect::<Vec<_>>(), - expected_relay_urls - ); - assert_eq!( - output - .relay_states - .iter() - .map(|relay_state| relay_state.fetch_status) - .collect::<Vec<_>>(), - expected_relay_states - .iter() - .map(|(_, fetch_status)| *fetch_status) - .collect::<Vec<_>>() - ); - for relay_state in &output.relay_states { - if relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Available { - assert!(relay_state.fetch_error.is_none()); - assert!(relay_state.live_groups.is_empty()); - } else { - assert!(relay_state.fetch_error.is_some()); - } - } - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn diff_live_nip89_reports_matched_after_publish() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = - MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); - let runtime = test_runtime.runtime; - let app_identity = myc::identity_files::load_encrypted_identity( - runtime - .config() - .discovery - .app_identity_path - .as_ref() - .expect("app identity path"), - )?; - - relay - .queue_publish_outcomes(app_identity.public_key(), &[true]) - .await; - let published = publish_nip89_event(&runtime).await?; - relay - .wait_for_published_events_by_author(app_identity.public_key(), 1) - .await?; - - let diff = diff_live_nip89(&runtime).await?; - - assert_eq!(diff.status, MycDiscoveryLiveStatus::Matched); - assert!(diff.differing_fields.is_empty()); - assert_eq!(diff.live_groups.len(), 1); - let live_event = diff.live_groups[0] - .events - .last() - .cloned() - .expect("live event"); - assert_eq!(live_event.event_id_hex, published.event.id.to_hex()); - assert_eq!( - live_event.handler.author_public_key_hex, - app_identity.public_key_hex() - ); - assert_eq!(live_event.handler.kinds, vec![24_133]); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn refresh_nip89_publishes_when_live_handler_is_missing() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = - MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); - let runtime = test_runtime.runtime; - let app_identity = myc::identity_files::load_encrypted_identity( - runtime - .config() - .discovery - .app_identity_path - .as_ref() - .expect("app identity path"), - )?; - - relay - .queue_publish_outcomes(app_identity.public_key(), &[true]) - .await; - - let refreshed = refresh_nip89(&runtime, false).await?; - - assert_eq!(refreshed.status, MycDiscoveryLiveStatus::Missing); - assert_eq!(refreshed.differing_fields, vec!["live_groups".to_owned()]); - assert!(refreshed.live_groups.is_empty()); - assert!(refreshed.published.is_some()); - assert_eq!(refreshed.repair_summary.repaired, 1); - assert_eq!(refreshed.repair_summary.failed, 0); - assert_eq!(refreshed.repair_summary.unchanged, 0); - assert_eq!(refreshed.repair_summary.skipped, 0); - assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new()); - assert_eq!(refreshed.repair_results.len(), 1); - assert_eq!( - refreshed.repair_results[0].outcome, - MycDiscoveryRepairOutcome::Repaired - ); - - let audit = wait_for_operation_audit_count(&runtime, 3).await?; - assert_eq!( - audit[0].operation, - MycOperationAuditKind::DiscoveryHandlerCompare - ); - assert_eq!(audit[0].outcome, MycOperationAuditOutcome::Missing); - assert_eq!( - audit[1].operation, - MycOperationAuditKind::DiscoveryHandlerPublish - ); - assert_eq!(audit[1].outcome, MycOperationAuditOutcome::Succeeded); - assert_eq!( - audit[2].operation, - MycOperationAuditKind::DiscoveryHandlerRepair - ); - assert_eq!(audit[2].outcome, MycOperationAuditOutcome::Succeeded); - let published = refreshed - .published - .as_ref() - .expect("published discovery output"); - let outbox_records = wait_for_delivery_outbox_records(&runtime, |records| { - !records.is_empty() && records[0].status == MycDeliveryOutboxStatus::Finalized - }) - .await?; - assert_eq!( - outbox_records[0].kind, - MycDeliveryOutboxKind::DiscoveryHandlerPublish - ); - assert_eq!( - outbox_records[0].request_id.as_deref(), - Some(published.event.id.to_hex().as_str()) - ); - assert_eq!( - outbox_records[0].attempt_id.as_deref(), - Some(refreshed.attempt_id.as_str()) - ); - assert!(outbox_records[0].signer_publish_workflow_id.is_none()); - assert!(outbox_records[0].published_at_unix.is_some()); - assert!(outbox_records[0].finalized_at_unix.is_some()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn refresh_nip89_repairs_missing_relays_without_republishing_matched_relays() -> TestResult<()> -{ - let relay_a = TestRelay::spawn().await?; - let relay_b = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new_with_discovery_relays( - &[relay_a.url(), relay_b.url()], - MycConnectionApproval::ExplicitUser, - ); - let runtime = test_runtime.runtime; - let app_identity = myc::identity_files::load_encrypted_identity( - runtime - .config() - .discovery - .app_identity_path - .as_ref() - .expect("app identity path"), - )?; - - let matched_event = MycDiscoveryContext::from_runtime(&runtime)? - .build_signed_handler_event() - .expect("matched event"); - publish_signed_event(relay_a.url(), &app_identity, &matched_event).await?; - relay_a - .wait_for_published_events_by_author(app_identity.public_key(), 1) - .await?; - - relay_b - .queue_publish_outcomes(app_identity.public_key(), &[true]) - .await; - let refreshed = refresh_nip89(&runtime, false).await?; - let published = refreshed.published.expect("published output"); - - assert_eq!(refreshed.status, MycDiscoveryLiveStatus::Matched); - assert_eq!(published.publish_relays, vec![relay_b.url().to_owned()]); - assert_eq!(published.relay_count, 1); - assert_eq!(published.acknowledged_relay_count, 1); - assert_eq!(refreshed.repair_summary.repaired, 1); - assert_eq!(refreshed.repair_summary.failed, 0); - assert_eq!(refreshed.repair_summary.unchanged, 1); - assert_eq!(refreshed.repair_summary.skipped, 0); - assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new()); - assert_eq!(refreshed.repair_results.len(), 2); - assert_eq!( - refreshed - .repair_results - .iter() - .find(|result| result.relay_url == relay_a.url()) - .expect("matched relay repair result") - .outcome, - MycDiscoveryRepairOutcome::Unchanged - ); - assert_eq!( - refreshed - .repair_results - .iter() - .find(|result| result.relay_url == relay_b.url()) - .expect("repaired relay result") - .outcome, - MycDiscoveryRepairOutcome::Repaired - ); - - relay_b - .wait_for_published_events_by_author(app_identity.public_key(), 1) - .await?; - assert_eq!( - relay_a - .published_events_by_author(app_identity.public_key()) - .await - .len(), - 1 - ); - assert_eq!( - relay_b - .published_events_by_author(app_identity.public_key()) - .await - .len(), - 1 - ); - - let diff = diff_live_nip89(&runtime).await?; - assert_eq!(diff.status, MycDiscoveryLiveStatus::Matched); - assert_eq!(diff.relay_summary.matched_relays.len(), 2); - assert!(diff.relay_summary.missing_relays.is_empty()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn refresh_nip89_skips_when_live_handler_matches() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = - MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); - let runtime = test_runtime.runtime; - let app_identity = myc::identity_files::load_encrypted_identity( - runtime - .config() - .discovery - .app_identity_path - .as_ref() - .expect("app identity path"), - )?; - - relay - .queue_publish_outcomes(app_identity.public_key(), &[true]) - .await; - publish_nip89_event(&runtime).await?; - relay - .wait_for_published_events_by_author(app_identity.public_key(), 1) - .await?; - - let refreshed = refresh_nip89(&runtime, false).await?; - - assert_eq!(refreshed.status, MycDiscoveryLiveStatus::Matched); - assert!(refreshed.differing_fields.is_empty()); - assert_eq!(refreshed.live_groups.len(), 1); - assert!(refreshed.published.is_none()); - assert_eq!(refreshed.repair_summary.repaired, 0); - assert_eq!(refreshed.repair_summary.failed, 0); - assert_eq!(refreshed.repair_summary.unchanged, 1); - assert_eq!(refreshed.repair_summary.skipped, 0); - assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new()); - assert_eq!(refreshed.repair_results.len(), 1); - assert_eq!( - refreshed.repair_results[0].outcome, - MycDiscoveryRepairOutcome::Unchanged - ); - - let audit = wait_for_operation_audit_count(&runtime, 4).await?; - assert_eq!( - audit[1].operation, - MycOperationAuditKind::DiscoveryHandlerCompare - ); - assert_eq!(audit[1].outcome, MycOperationAuditOutcome::Matched); - assert_eq!( - audit[2].operation, - MycOperationAuditKind::DiscoveryHandlerRepair - ); - assert_eq!(audit[2].outcome, MycOperationAuditOutcome::Matched); - assert_eq!( - audit[3].operation, - MycOperationAuditKind::DiscoveryHandlerRefresh - ); - assert_eq!(audit[3].outcome, MycOperationAuditOutcome::Skipped); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn refresh_nip89_republishes_when_live_handler_drifted() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = - MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); - let runtime = test_runtime.runtime; - let app_identity = myc::identity_files::load_encrypted_identity( - runtime - .config() - .discovery - .app_identity_path - .as_ref() - .expect("app identity path"), - )?; - - let mut drifted_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]) - .with_identifier("myc".to_owned()) - .with_relays(vec!["wss://wrong.example.com".to_owned()]); - drifted_spec = drifted_spec.with_nostr_connect_url( - "https://wrong.example.com/connect?uri=nostrconnect%3A%2F%2Fstale".to_owned(), - ); - let metadata = RadrootsNostrMetadata { - name: Some("stale".to_owned()), - ..RadrootsNostrMetadata::default() - }; - drifted_spec = drifted_spec.with_metadata(metadata); - publish_handler_event(relay.url(), &app_identity, &drifted_spec).await?; - relay - .wait_for_published_events_by_author(app_identity.public_key(), 1) - .await?; - - relay - .queue_publish_outcomes(app_identity.public_key(), &[true]) - .await; - let refreshed = refresh_nip89(&runtime, false).await?; - - assert_eq!(refreshed.status, MycDiscoveryLiveStatus::Drifted); - assert_eq!(refreshed.live_groups.len(), 1); - assert!(refreshed.published.is_some()); - assert_eq!(refreshed.repair_summary.repaired, 1); - assert_eq!(refreshed.repair_summary.failed, 0); - assert_eq!(refreshed.repair_summary.unchanged, 0); - assert_eq!(refreshed.repair_summary.skipped, 0); - assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new()); - assert_eq!(refreshed.repair_results.len(), 1); - assert_eq!( - refreshed.repair_results[0].outcome, - MycDiscoveryRepairOutcome::Repaired - ); - assert!( - refreshed - .differing_fields - .iter() - .any(|field| field == "relays" || field == "nostrconnect_url" || field == "metadata") - ); - - let audit = wait_for_operation_audit_count(&runtime, 3).await?; - assert_eq!( - audit[0].operation, - MycOperationAuditKind::DiscoveryHandlerCompare - ); - assert_eq!(audit[0].outcome, MycOperationAuditOutcome::Drifted); - assert_eq!( - audit[1].operation, - MycOperationAuditKind::DiscoveryHandlerPublish - ); - assert_eq!(audit[1].outcome, MycOperationAuditOutcome::Succeeded); - assert_eq!( - audit[2].operation, - MycOperationAuditKind::DiscoveryHandlerRepair - ); - assert_eq!(audit[2].outcome, MycOperationAuditOutcome::Succeeded); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn refresh_nip89_repairs_drifted_relays_without_force_when_other_relays_match() --> TestResult<()> { - let relay_a = TestRelay::spawn().await?; - let relay_b = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new_with_discovery_relays( - &[relay_a.url(), relay_b.url()], - MycConnectionApproval::ExplicitUser, - ); - let runtime = test_runtime.runtime; - let app_identity = myc::identity_files::load_encrypted_identity( - runtime - .config() - .discovery - .app_identity_path - .as_ref() - .expect("app identity path"), - )?; - - let matched_event = MycDiscoveryContext::from_runtime(&runtime)? - .build_signed_handler_event() - .expect("matched event"); - publish_signed_event(relay_a.url(), &app_identity, &matched_event).await?; - - let drifted_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]) - .with_identifier("myc".to_owned()) - .with_relays(vec!["wss://stale.example.com".to_owned()]); - publish_handler_event(relay_b.url(), &app_identity, &drifted_spec).await?; - - relay_a - .wait_for_published_events_by_author(app_identity.public_key(), 1) - .await?; - relay_b - .wait_for_published_events_by_author(app_identity.public_key(), 1) - .await?; - - relay_b - .queue_publish_outcomes(app_identity.public_key(), &[true]) - .await; - let refreshed = refresh_nip89(&runtime, false).await?; - let published = refreshed.published.expect("published output"); - - assert_eq!(refreshed.status, MycDiscoveryLiveStatus::Conflicted); - assert_eq!(published.publish_relays, vec![relay_b.url().to_owned()]); - assert_eq!(published.relay_count, 1); - assert_eq!(published.acknowledged_relay_count, 1); - assert_eq!(refreshed.repair_summary.repaired, 1); - assert_eq!(refreshed.repair_summary.failed, 0); - assert_eq!(refreshed.repair_summary.unchanged, 1); - assert_eq!(refreshed.repair_summary.skipped, 0); - assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new()); - assert_eq!(refreshed.repair_results.len(), 2); - assert_eq!( - refreshed - .repair_results - .iter() - .find(|result| result.relay_url == relay_a.url()) - .expect("matched relay result") - .outcome, - MycDiscoveryRepairOutcome::Unchanged - ); - assert_eq!( - refreshed - .repair_results - .iter() - .find(|result| result.relay_url == relay_b.url()) - .expect("repaired relay result") - .outcome, - MycDiscoveryRepairOutcome::Repaired - ); - - relay_b - .wait_for_published_events_by_author(app_identity.public_key(), 2) - .await?; - assert_eq!( - relay_a - .published_events_by_author(app_identity.public_key()) - .await - .len(), - 1 - ); - assert_eq!( - relay_b - .published_events_by_author(app_identity.public_key()) - .await - .len(), - 2 - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn refresh_nip89_reports_remaining_relays_after_mixed_targeted_repair() -> TestResult<()> { - let relay_a = TestRelay::spawn().await?; - let relay_b = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new_with_discovery_relays( - &[relay_a.url(), relay_b.url()], - MycConnectionApproval::ExplicitUser, - ); - let runtime = test_runtime.runtime; - let app_identity = myc::identity_files::load_encrypted_identity( - runtime - .config() - .discovery - .app_identity_path - .as_ref() - .expect("app identity path"), - )?; - - relay_a - .queue_publish_outcomes(app_identity.public_key(), &[true]) - .await; - relay_b - .queue_publish_outcomes(app_identity.public_key(), &[false]) - .await; - - let refreshed = refresh_nip89(&runtime, false).await?; - let published = refreshed.published.expect("published output"); - - assert_eq!(refreshed.status, MycDiscoveryLiveStatus::Missing); - assert_eq!( - published.publish_relays, - vec![relay_a.url().to_owned(), relay_b.url().to_owned()] - ); - assert_eq!(published.relay_count, 2); - assert_eq!(published.acknowledged_relay_count, 1); - assert_eq!(published.relay_results.len(), 2); - assert_eq!(refreshed.repair_summary.repaired, 1); - assert_eq!(refreshed.repair_summary.failed, 1); - assert_eq!(refreshed.repair_summary.unchanged, 0); - assert_eq!(refreshed.repair_summary.skipped, 0); - assert_eq!(refreshed.repair_results.len(), 2); - assert_eq!( - refreshed.remaining_repair_relays, - vec![relay_b.url().to_owned()] - ); - - let repaired = refreshed - .repair_results - .iter() - .find(|result| result.relay_url == relay_a.url()) - .expect("repaired relay result"); - assert_eq!(repaired.outcome, MycDiscoveryRepairOutcome::Repaired); - - let failed = refreshed - .repair_results - .iter() - .find(|result| result.relay_url == relay_b.url()) - .expect("failed relay result"); - assert_eq!(failed.outcome, MycDiscoveryRepairOutcome::Failed); - assert!( - failed - .detail - .as_deref() - .unwrap_or_default() - .contains("blocked by test relay") - ); - - relay_a - .wait_for_published_events_by_author(app_identity.public_key(), 1) - .await?; - assert_eq!( - relay_b - .published_events_by_author(app_identity.public_key()) - .await - .len(), - 0 - ); - - let diff = diff_live_nip89(&runtime).await?; - assert_eq!(diff.status, MycDiscoveryLiveStatus::Matched); - assert_eq!( - diff.relay_summary.matched_relays, - vec![relay_a.url().to_owned()] - ); - assert_eq!( - diff.relay_summary.missing_relays, - vec![relay_b.url().to_owned()] - ); - - let audit = wait_for_operation_audit_count(&runtime, 4).await?; - assert_eq!( - audit[0].operation, - MycOperationAuditKind::DiscoveryHandlerCompare - ); - assert_eq!(audit[0].outcome, MycOperationAuditOutcome::Missing); - assert_eq!( - audit[1].operation, - MycOperationAuditKind::DiscoveryHandlerPublish - ); - assert_eq!(audit[1].outcome, MycOperationAuditOutcome::Succeeded); - assert_eq!( - audit[2].operation, - MycOperationAuditKind::DiscoveryHandlerRepair - ); - assert_eq!(audit[2].outcome, MycOperationAuditOutcome::Succeeded); - assert_eq!(audit[2].relay_url.as_deref(), Some(relay_a.url())); - assert_eq!( - audit[3].operation, - MycOperationAuditKind::DiscoveryHandlerRepair - ); - assert_eq!(audit[3].outcome, MycOperationAuditOutcome::Rejected); - assert_eq!(audit[3].relay_url.as_deref(), Some(relay_b.url())); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn diff_live_nip89_reports_conflicted_when_live_groups_disagree() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = - MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); - let runtime = test_runtime.runtime; - let app_identity = myc::identity_files::load_encrypted_identity( - runtime - .config() - .discovery - .app_identity_path - .as_ref() - .expect("app identity path"), - )?; - - let first_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]) - .with_identifier("myc".to_owned()) - .with_relays(vec!["wss://relay-a.example.com".to_owned()]); - publish_handler_event(relay.url(), &app_identity, &first_spec).await?; - - let second_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]) - .with_identifier("myc".to_owned()) - .with_relays(vec!["wss://relay-b.example.com".to_owned()]); - publish_handler_event(relay.url(), &app_identity, &second_spec).await?; - - relay - .wait_for_published_events_by_author(app_identity.public_key(), 2) - .await?; - - let diff = diff_live_nip89(&runtime).await?; - - assert_eq!(diff.status, MycDiscoveryLiveStatus::Conflicted); - assert_eq!(diff.differing_fields, vec!["live_groups".to_owned()]); - assert_eq!(diff.live_groups.len(), 2); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn diff_live_nip89_surfaces_relay_divergence_with_provenance() -> TestResult<()> { - let relay_a = TestRelay::spawn().await?; - let relay_b = TestRelay::spawn().await?; - let test_runtime = MycTestRuntime::new_with_discovery_relays( - &[relay_a.url(), relay_b.url()], - MycConnectionApproval::ExplicitUser, - ); - let runtime = test_runtime.runtime; - let app_identity = myc::identity_files::load_encrypted_identity( - runtime - .config() - .discovery - .app_identity_path - .as_ref() - .expect("app identity path"), - )?; - - let matched_event = MycDiscoveryContext::from_runtime(&runtime)? - .build_signed_handler_event() - .expect("matched event"); - publish_signed_event(relay_a.url(), &app_identity, &matched_event).await?; - - let mut drifted_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]) - .with_identifier("myc".to_owned()) - .with_relays(vec!["wss://stale.example.com".to_owned()]); - let drifted_metadata = RadrootsNostrMetadata { - name: Some("stale".to_owned()), - ..RadrootsNostrMetadata::default() - }; - drifted_spec = drifted_spec.with_metadata(drifted_metadata); - publish_handler_event(relay_b.url(), &app_identity, &drifted_spec).await?; - - relay_a - .wait_for_published_events_by_author(app_identity.public_key(), 1) - .await?; - relay_b - .wait_for_published_events_by_author(app_identity.public_key(), 1) - .await?; - - let diff = diff_live_nip89(&runtime).await?; - - assert_eq!(diff.status, MycDiscoveryLiveStatus::Conflicted); - assert_eq!(diff.live_groups.len(), 2); - assert_eq!(diff.relay_states.len(), 2); - assert_eq!(diff.relay_summary.total_relays, 2); - assert_eq!( - diff.relay_summary.matched_relays, - vec![relay_a.url().to_owned()] - ); - assert_eq!( - diff.relay_summary.drifted_relays, - vec![relay_b.url().to_owned()] - ); - assert!(diff.relay_summary.unavailable_relays.is_empty()); - assert!(diff.relay_summary.missing_relays.is_empty()); - assert!(diff.relay_summary.conflicted_relays.is_empty()); - - let matched_relay = diff - .relay_states - .iter() - .find(|relay_state| relay_state.relay_url == relay_a.url()) - .expect("matched relay"); - assert_eq!( - matched_relay.fetch_status, - MycDiscoveryRelayFetchStatus::Available - ); - assert_eq!( - matched_relay.live_status, - Some(MycDiscoveryLiveStatus::Matched) - ); - assert_eq!(matched_relay.live_groups.len(), 1); - assert_eq!( - matched_relay.live_groups[0].source_relays, - vec![relay_a.url().to_owned()] - ); - - let drifted_relay = diff - .relay_states - .iter() - .find(|relay_state| relay_state.relay_url == relay_b.url()) - .expect("drifted relay"); - assert_eq!( - drifted_relay.fetch_status, - MycDiscoveryRelayFetchStatus::Available - ); - assert_eq!( - drifted_relay.live_status, - Some(MycDiscoveryLiveStatus::Drifted) - ); - assert_eq!(drifted_relay.live_groups.len(), 1); - assert_eq!( - drifted_relay.live_groups[0].source_relays, - vec![relay_b.url().to_owned()] - ); - - let live_group_relays = diff - .live_groups - .iter() - .map(|group| group.source_relays.clone()) - .collect::<Vec<_>>(); - assert!(live_group_relays.contains(&vec![relay_a.url().to_owned()])); - assert!(live_group_relays.contains(&vec![relay_b.url().to_owned()])); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn refresh_nip89_requires_force_when_any_discovery_relay_is_unavailable() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let unavailable_relay = unavailable_relay_url()?; - let test_runtime = MycTestRuntime::new_with_discovery_relays( - &[relay.url(), unavailable_relay.as_str()], - MycConnectionApproval::ExplicitUser, - ); - let runtime = test_runtime.runtime; - let app_identity = myc::identity_files::load_encrypted_identity( - runtime - .config() - .discovery - .app_identity_path - .as_ref() - .expect("app identity path"), - )?; - - let diff = diff_live_nip89(&runtime).await?; - assert_eq!(diff.status, MycDiscoveryLiveStatus::Missing); - assert_eq!( - diff.relay_summary.unavailable_relays, - vec![unavailable_relay.clone()] - ); - assert_eq!( - diff.relay_summary.missing_relays, - vec![relay.url().to_owned()] - ); - - let unavailable_state = diff - .relay_states - .iter() - .find(|relay_state| relay_state.relay_url == unavailable_relay) - .expect("unavailable relay"); - assert_eq!( - unavailable_state.fetch_status, - MycDiscoveryRelayFetchStatus::Unavailable - ); - assert_eq!(unavailable_state.live_status, None); - assert!(unavailable_state.fetch_error.is_some()); - - let error = refresh_nip89(&runtime, false) - .await - .expect_err("refresh without force should fail when a relay is unavailable"); - assert!(error.to_string().contains("unavailable")); - - let audit = wait_for_operation_audit_count(&runtime, 4).await?; - assert_eq!( - audit[0].operation, - MycOperationAuditKind::DiscoveryHandlerFetch - ); - assert_eq!(audit[0].outcome, MycOperationAuditOutcome::Unavailable); - assert_eq!( - audit[1].operation, - MycOperationAuditKind::DiscoveryHandlerFetch - ); - assert_eq!(audit[1].outcome, MycOperationAuditOutcome::Unavailable); - assert_eq!( - audit[2].operation, - MycOperationAuditKind::DiscoveryHandlerCompare - ); - assert_eq!(audit[2].outcome, MycOperationAuditOutcome::Missing); - assert_eq!( - audit[3].operation, - MycOperationAuditKind::DiscoveryHandlerRefresh - ); - assert_eq!(audit[3].outcome, MycOperationAuditOutcome::Unavailable); - - relay - .queue_publish_outcomes(app_identity.public_key(), &[true]) - .await; - let refreshed = refresh_nip89(&runtime, true).await?; - assert_eq!(refreshed.status, MycDiscoveryLiveStatus::Missing); - assert_eq!( - refreshed.relay_summary.unavailable_relays, - vec![unavailable_relay.clone()] - ); - assert!(refreshed.published.is_some()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial_test::serial] -async fn refresh_nip89_requires_force_when_live_handler_is_conflicted() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let test_runtime = - MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); - let runtime = test_runtime.runtime; - let app_identity = myc::identity_files::load_encrypted_identity( - runtime - .config() - .discovery - .app_identity_path - .as_ref() - .expect("app identity path"), - )?; - - let first_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]) - .with_identifier("myc".to_owned()) - .with_relays(vec!["wss://relay-a.example.com".to_owned()]); - publish_handler_event(relay.url(), &app_identity, &first_spec).await?; - - let second_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]) - .with_identifier("myc".to_owned()) - .with_relays(vec!["wss://relay-b.example.com".to_owned()]); - publish_handler_event(relay.url(), &app_identity, &second_spec).await?; - - relay - .wait_for_published_events_by_author(app_identity.public_key(), 2) - .await?; - - let error = refresh_nip89(&runtime, false) - .await - .expect_err("conflicted refresh without force should fail"); - assert!( - error - .to_string() - .contains("live discovery handler state is conflicted") - ); - - let audit = wait_for_operation_audit_count(&runtime, 2).await?; - assert_eq!( - audit[0].operation, - MycOperationAuditKind::DiscoveryHandlerCompare - ); - assert_eq!(audit[0].outcome, MycOperationAuditOutcome::Conflicted); - assert_eq!( - audit[1].operation, - MycOperationAuditKind::DiscoveryHandlerRefresh - ); - assert_eq!(audit[1].outcome, MycOperationAuditOutcome::Conflicted); - - relay - .queue_publish_outcomes(app_identity.public_key(), &[true]) - .await; - let refreshed = refresh_nip89(&runtime, true).await?; - assert_eq!(refreshed.status, MycDiscoveryLiveStatus::Conflicted); - assert_eq!(refreshed.live_groups.len(), 2); - assert!(refreshed.published.is_some()); - - Ok(()) -} diff --git a/tests/operability_e2e.rs b/tests/operability_e2e.rs @@ -1,425 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::time::Duration; -use std::time::{SystemTime, UNIX_EPOCH}; - -use myc::host_identity::RadrootsIdentity; -use myc::nostr_contract::{ - RadrootsNostrGenericEventBuilder, RadrootsNostrKind, RadrootsNostrRelayUrl, -}; -use myc::signer::prelude::{ - RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerConnectionDraft, -}; -use myc::{ - MycActiveIdentity, MycConfig, MycDeliveryOutboxKind, MycDeliveryOutboxRecord, - MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord, MycRuntime, - MycRuntimeAuditBackend, MycRuntimeStatus, MycSignerStateBackend, MycTransportDeliveryPolicy, - collect_status_full, -}; -use tokio::net::TcpListener; -use tokio::sync::oneshot; -use tokio::time::sleep; - -mod support; - -type TestResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>; - -struct TestRelay { - url: String, - shutdown_tx: Option<oneshot::Sender<()>>, -} - -impl TestRelay { - async fn spawn() -> TestResult<Self> { - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - let url = format!("ws://{addr}"); - let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); - - tokio::spawn(async move { - loop { - tokio::select! { - _ = &mut shutdown_rx => break, - accept = listener.accept() => { - let Ok((stream, _)) = accept else { - break; - }; - tokio::spawn(async move { - let _ = tokio_tungstenite::accept_async(stream).await; - }); - } - } - } - }); - - Ok(Self { - url, - shutdown_tx: Some(shutdown_tx), - }) - } - - fn url(&self) -> &str { - self.url.as_str() - } -} - -impl Drop for TestRelay { - fn drop(&mut self) { - if let Some(shutdown_tx) = self.shutdown_tx.take() { - let _ = shutdown_tx.send(()); - } - } -} - -struct HangingRelay { - url: String, - shutdown_tx: Option<oneshot::Sender<()>>, -} - -impl HangingRelay { - async fn spawn(hold_open_for: Duration) -> TestResult<Self> { - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - let url = format!("ws://{addr}"); - let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); - - tokio::spawn(async move { - loop { - tokio::select! { - _ = &mut shutdown_rx => break, - accept = listener.accept() => { - let Ok((stream, _)) = accept else { - break; - }; - tokio::spawn(async move { - sleep(hold_open_for).await; - drop(stream); - }); - } - } - } - }); - - Ok(Self { - url, - shutdown_tx: Some(shutdown_tx), - }) - } - - fn url(&self) -> &str { - self.url.as_str() - } -} - -impl Drop for HangingRelay { - fn drop(&mut self) { - if let Some(shutdown_tx) = self.shutdown_tx.take() { - let _ = shutdown_tx.send(()); - } - } -} - -fn write_test_identity(path: &Path, secret_key: &str) { - let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity from secret"); - myc::identity_files::store_encrypted_identity(path, &identity).expect("write identity"); -} - -fn signed_delivery_event(identity: &MycActiveIdentity, content: &str) -> nostr::Event { - identity - .sign_protocol_event_builder( - RadrootsNostrGenericEventBuilder::new(RadrootsNostrKind::Custom(24133), content), - "operability delivery test event", - ) - .expect("sign event") -} - -fn now_unix_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time") - .as_secs() -} - -fn build_runtime<F>(configure: F) -> MycRuntime -where - F: FnOnce(&mut MycConfig), -{ - let temp = tempfile::tempdir().expect("tempdir").keep(); - let mut config = support::repo_local_config(PathBuf::from(&temp).as_path()); - config.transport.connect_timeout_secs = 1; - let signer_identity_path = config.paths().signer_identity_path().to_path_buf(); - let user_identity_path = config.paths().user_identity_path().to_path_buf(); - write_test_identity( - &signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_test_identity( - &user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - configure(&mut config); - MycRuntime::bootstrap(config).expect("runtime") -} - -#[tokio::test] -async fn status_is_unready_when_transport_is_disabled() -> TestResult<()> { - let runtime = build_runtime(|_| {}); - - let status = collect_status_full(&runtime).await?; - - assert_eq!(status.status, MycRuntimeStatus::Unready); - assert!(!status.ready); - assert_eq!(status.transport.status, MycRuntimeStatus::Unready); - assert!( - status - .reasons - .iter() - .any(|reason| reason == "transport is disabled") - ); - Ok(()) -} - -#[tokio::test] -async fn status_is_degraded_but_ready_when_any_policy_has_one_live_relay() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let hanging = HangingRelay::spawn(Duration::from_secs(5)).await?; - let runtime = build_runtime(|config| { - config.transport.enabled = true; - config.transport.relays = vec![relay.url().to_owned(), hanging.url().to_owned()]; - config.transport.delivery_policy = MycTransportDeliveryPolicy::Any; - }); - - let status = collect_status_full(&runtime).await?; - - assert_eq!(status.status, MycRuntimeStatus::Degraded); - assert!(status.ready); - assert_eq!(status.transport.status, MycRuntimeStatus::Degraded); - assert_eq!(status.transport.available_relay_count, 1); - assert_eq!(status.transport.unavailable_relay_count, 1); - Ok(()) -} - -#[tokio::test] -async fn status_is_unready_when_all_policy_cannot_be_satisfied() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let hanging = HangingRelay::spawn(Duration::from_secs(5)).await?; - let runtime = build_runtime(|config| { - config.transport.enabled = true; - config.transport.relays = vec![relay.url().to_owned(), hanging.url().to_owned()]; - config.transport.delivery_policy = MycTransportDeliveryPolicy::All; - }); - - let status = collect_status_full(&runtime).await?; - - assert_eq!(status.status, MycRuntimeStatus::Unready); - assert!(!status.ready); - assert_eq!(status.transport.status, MycRuntimeStatus::Unready); - assert_eq!(status.transport.available_relay_count, 1); - assert_eq!(status.transport.required_available_relays, 2); - Ok(()) -} - -#[tokio::test] -async fn status_is_unready_when_critical_delivery_job_is_blocked() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let relay_url: RadrootsNostrRelayUrl = relay.url().parse()?; - let runtime = build_runtime(|config| { - config.transport.enabled = true; - config.transport.relays = vec![relay.url().to_owned()]; - }); - let client_identity = RadrootsIdentity::from_secret_key_str( - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - )?; - let manager = runtime.signer_manager()?; - let connection = manager.register_connection( - RadrootsNostrSignerConnectionDraft::new( - client_identity.public_key(), - runtime.user_public_identity(), - ) - .with_connect_secret("blocked-secret") - .with_relays(vec![relay_url.clone()]) - .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::NotRequired), - )?; - let workflow = manager.begin_connect_secret_publish_finalization(&connection.connection_id)?; - let outbox_record = MycDeliveryOutboxRecord::new( - MycDeliveryOutboxKind::ListenerResponsePublish, - signed_delivery_event(runtime.signer_identity(), "blocked-listener"), - vec![relay_url], - )? - .with_connection_id(&connection.connection_id) - .with_request_id("blocked-request") - .with_signer_publish_workflow_id(&workflow.workflow_id); - runtime.delivery_outbox_store().enqueue(&outbox_record)?; - manager.cancel_publish_workflow(&workflow.workflow_id)?; - - let status = collect_status_full(&runtime).await?; - - assert_eq!(status.transport.status, MycRuntimeStatus::Healthy); - assert_eq!(status.status, MycRuntimeStatus::Unready); - assert!(!status.ready); - assert_eq!(status.delivery_outbox.status, MycRuntimeStatus::Unready); - assert!(!status.delivery_outbox.ready); - assert_eq!(status.delivery_outbox.unfinished_job_count, 1); - assert_eq!(status.delivery_outbox.critical_unfinished_job_count, 1); - assert_eq!(status.delivery_outbox.blocked_job_count, 1); - assert_eq!(status.delivery_outbox.critical_blocked_job_count, 1); - assert!( - status - .reasons - .iter() - .any(|reason| reason == "1 critical delivery outbox job(s) are blocked") - ); - Ok(()) -} - -#[tokio::test] -async fn status_is_degraded_but_ready_when_only_discovery_job_is_stuck() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let relay_url: RadrootsNostrRelayUrl = relay.url().parse()?; - let runtime = build_runtime(|config| { - config.transport.enabled = true; - config.transport.relays = vec![relay.url().to_owned()]; - config.transport.connect_timeout_secs = 1; - }); - let mut outbox_record = MycDeliveryOutboxRecord::new( - MycDeliveryOutboxKind::DiscoveryHandlerPublish, - signed_delivery_event(runtime.signer_identity(), "stuck-discovery"), - vec![relay_url], - )? - .with_attempt_id("discovery-attempt-1"); - let old_timestamp = now_unix_secs().saturating_sub(30); - outbox_record.created_at_unix = old_timestamp; - outbox_record.updated_at_unix = old_timestamp; - runtime.delivery_outbox_store().enqueue(&outbox_record)?; - - let status = collect_status_full(&runtime).await?; - - assert_eq!(status.transport.status, MycRuntimeStatus::Healthy); - assert_eq!(status.status, MycRuntimeStatus::Degraded); - assert!(status.ready); - assert_eq!(status.delivery_outbox.status, MycRuntimeStatus::Degraded); - assert!(status.delivery_outbox.ready); - assert_eq!(status.delivery_outbox.unfinished_job_count, 1); - assert_eq!(status.delivery_outbox.critical_unfinished_job_count, 0); - assert_eq!(status.delivery_outbox.blocked_job_count, 1); - assert_eq!(status.delivery_outbox.critical_blocked_job_count, 0); - assert_eq!(status.delivery_outbox.oldest_blocked_age_secs, Some(30)); - assert!( - status - .reasons - .iter() - .any(|reason| reason == "1 non-critical delivery outbox job(s) are blocked") - ); - Ok(()) -} - -#[tokio::test] -async fn status_surfaces_last_delivery_recovery_result() -> TestResult<()> { - let runtime = build_runtime(|_| {}); - runtime.record_operation_audit(&MycOperationAuditRecord::new( - MycOperationAuditKind::DeliveryRecovery, - MycOperationAuditOutcome::Succeeded, - None, - None, - 2, - 2, - "recovered 2/2 delivery outbox job(s); republished 1", - )); - - let status = collect_status_full(&runtime).await?; - let last_recovery = status - .delivery_outbox - .last_recovery - .expect("last delivery recovery"); - - assert_eq!(last_recovery.outcome, MycOperationAuditOutcome::Succeeded); - assert_eq!( - last_recovery.summary, - "recovered 2/2 delivery outbox job(s); republished 1" - ); - Ok(()) -} - -#[tokio::test] -async fn status_reports_sqlite_persistence_schema_state() -> TestResult<()> { - let runtime = build_runtime(|config| { - config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; - config.persistence.runtime_audit_backend = MycRuntimeAuditBackend::Sqlite; - }); - - let status = collect_status_full(&runtime).await?; - - assert_eq!( - status.persistence.signer_state.backend, - MycSignerStateBackend::Sqlite - ); - assert!(status.persistence.signer_state.exists); - assert_eq!( - status - .persistence - .signer_state - .sqlite_schema - .as_ref() - .expect("signer sqlite schema") - .applied_migration_count, - Some(3) - ); - assert_eq!( - status - .persistence - .signer_state - .sqlite_schema - .as_ref() - .expect("signer sqlite schema") - .journal_mode - .as_deref(), - Some("wal") - ); - assert_eq!( - status - .persistence - .signer_state - .sqlite_schema - .as_ref() - .expect("signer sqlite schema") - .store_version, - Some(1) - ); - assert_eq!( - status.persistence.runtime_audit.backend, - MycRuntimeAuditBackend::Sqlite - ); - assert!(status.persistence.runtime_audit.exists); - assert_eq!( - status - .persistence - .runtime_audit - .sqlite_schema - .as_ref() - .expect("audit sqlite schema") - .applied_migration_count, - Some(1) - ); - assert_eq!( - status - .persistence - .runtime_audit - .sqlite_schema - .as_ref() - .expect("audit sqlite schema") - .latest_migration - .as_deref(), - Some("0000_runtime_audit_init") - ); - assert_eq!( - status - .persistence - .runtime_audit - .sqlite_schema - .as_ref() - .expect("audit sqlite schema") - .journal_mode - .as_deref(), - Some("wal") - ); - Ok(()) -} diff --git a/tests/operability_server.rs b/tests/operability_server.rs @@ -1,280 +0,0 @@ -use std::net::{SocketAddr, TcpListener as StdTcpListener}; -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use myc::host_identity::RadrootsIdentity; -use myc::{MycConfig, MycRuntime, MycTransportDeliveryPolicy}; -use serde_json::Value; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::{TcpListener, TcpStream}; -use tokio::sync::oneshot; -use tokio::time::{sleep, timeout}; - -mod support; - -type TestResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>; - -const HTTP_READY_TIMEOUT: Duration = Duration::from_secs(15); - -struct TestRelay { - url: String, - shutdown_tx: Option<oneshot::Sender<()>>, -} - -impl TestRelay { - async fn spawn() -> TestResult<Self> { - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - let url = format!("ws://{addr}"); - let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); - - tokio::spawn(async move { - loop { - tokio::select! { - _ = &mut shutdown_rx => break, - accept = listener.accept() => { - let Ok((stream, _)) = accept else { - break; - }; - tokio::spawn(async move { - let _ = tokio_tungstenite::accept_async(stream).await; - }); - } - } - } - }); - - Ok(Self { - url, - shutdown_tx: Some(shutdown_tx), - }) - } - - fn url(&self) -> &str { - self.url.as_str() - } -} - -impl Drop for TestRelay { - fn drop(&mut self) { - if let Some(shutdown_tx) = self.shutdown_tx.take() { - let _ = shutdown_tx.send(()); - } - } -} - -struct HangingRelay { - url: String, - shutdown_tx: Option<oneshot::Sender<()>>, -} - -impl HangingRelay { - async fn spawn(hold_open_for: Duration) -> TestResult<Self> { - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - let url = format!("ws://{addr}"); - let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); - - tokio::spawn(async move { - loop { - tokio::select! { - _ = &mut shutdown_rx => break, - accept = listener.accept() => { - let Ok((stream, _)) = accept else { - break; - }; - tokio::spawn(async move { - sleep(hold_open_for).await; - drop(stream); - }); - } - } - } - }); - - Ok(Self { - url, - shutdown_tx: Some(shutdown_tx), - }) - } - - fn url(&self) -> &str { - self.url.as_str() - } -} - -impl Drop for HangingRelay { - fn drop(&mut self) { - if let Some(shutdown_tx) = self.shutdown_tx.take() { - let _ = shutdown_tx.send(()); - } - } -} - -fn write_test_identity(path: &Path, secret_key: &str) { - let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity from secret"); - myc::identity_files::store_encrypted_identity(path, &identity).expect("write identity"); -} - -fn free_loopback_addr() -> SocketAddr { - let listener = StdTcpListener::bind("127.0.0.1:0").expect("bind free loopback addr"); - let addr = listener.local_addr().expect("local addr"); - drop(listener); - addr -} - -fn build_runtime<F>(configure: F) -> (MycRuntime, SocketAddr) -where - F: FnOnce(&mut MycConfig), -{ - let temp = tempfile::tempdir().expect("tempdir").keep(); - let bind_addr = free_loopback_addr(); - let mut config = support::repo_local_config(PathBuf::from(&temp).as_path()); - config.transport.connect_timeout_secs = 1; - config.observability.enabled = true; - config.observability.bind_addr = bind_addr; - let signer_identity_path = config.paths().signer_identity_path().to_path_buf(); - let user_identity_path = config.paths().user_identity_path().to_path_buf(); - write_test_identity( - &signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_test_identity( - &user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - configure(&mut config); - (MycRuntime::bootstrap(config).expect("runtime"), bind_addr) -} - -async fn spawn_runtime(runtime: MycRuntime) -> oneshot::Sender<()> { - let (shutdown_tx, shutdown_rx) = oneshot::channel(); - tokio::spawn(async move { - let _ = runtime - .run_until(async move { - let _ = shutdown_rx.await; - }) - .await; - }); - shutdown_tx -} - -async fn wait_for_http(addr: SocketAddr) -> TestResult<()> { - timeout(HTTP_READY_TIMEOUT, async { - loop { - match TcpStream::connect(addr).await { - Ok(mut stream) => { - let _ = stream.shutdown().await; - return; - } - Err(_) => sleep(Duration::from_millis(50)).await, - } - } - }) - .await?; - Ok(()) -} - -struct SimpleHttpResponse { - status: u16, - content_type: Option<String>, - body: String, -} - -async fn http_get(addr: SocketAddr, path: &str) -> TestResult<SimpleHttpResponse> { - let mut stream = TcpStream::connect(addr).await?; - let request = format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"); - stream.write_all(request.as_bytes()).await?; - let mut response = Vec::new(); - stream.read_to_end(&mut response).await?; - let response = String::from_utf8(response)?; - let (head, body) = response - .split_once("\r\n\r\n") - .ok_or("missing http body separator")?; - let mut lines = head.lines(); - let status_line = lines.next().ok_or("missing status line")?; - let status = status_line - .split_whitespace() - .nth(1) - .ok_or("missing status code")? - .parse::<u16>()?; - let content_type = lines.find_map(|line| { - let (key, value) = line.split_once(':')?; - if key.eq_ignore_ascii_case("content-type") { - Some(value.trim().to_owned()) - } else { - None - } - }); - Ok(SimpleHttpResponse { - status, - content_type, - body: body.to_owned(), - }) -} - -#[tokio::test] -async fn observability_server_reports_unready_when_transport_is_disabled() -> TestResult<()> { - let (runtime, bind_addr) = build_runtime(|_| {}); - let shutdown_tx = spawn_runtime(runtime).await; - wait_for_http(bind_addr).await?; - - let health = http_get(bind_addr, "/healthz").await?; - assert_eq!(health.status, 503); - assert_eq!(health.body, "unready"); - - let ready = http_get(bind_addr, "/readyz").await?; - assert_eq!(ready.status, 503); - assert_eq!(ready.body, "unready"); - - let status = http_get(bind_addr, "/status").await?; - assert_eq!(status.status, 200); - let body: Value = serde_json::from_str(status.body.as_str())?; - assert_eq!(body["status"], "unready"); - assert_eq!(body["ready"], false); - - let metrics = http_get(bind_addr, "/metrics").await?; - assert_eq!(metrics.status, 200); - assert!( - metrics - .content_type - .as_deref() - .unwrap_or_default() - .starts_with("text/plain") - ); - assert!(metrics.body.contains("myc_runtime_operation_total")); - - let _ = shutdown_tx.send(()); - Ok(()) -} - -#[tokio::test] -async fn observability_server_reports_degraded_but_ready_partial_outage() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let hanging = HangingRelay::spawn(Duration::from_secs(5)).await?; - let (runtime, bind_addr) = build_runtime(|config| { - config.transport.enabled = true; - config.transport.relays = vec![relay.url().to_owned(), hanging.url().to_owned()]; - config.transport.delivery_policy = MycTransportDeliveryPolicy::Any; - }); - let shutdown_tx = spawn_runtime(runtime).await; - wait_for_http(bind_addr).await?; - - let health = http_get(bind_addr, "/healthz").await?; - assert_eq!(health.status, 200); - assert_eq!(health.body, "degraded"); - - let ready = http_get(bind_addr, "/readyz").await?; - assert_eq!(ready.status, 200); - assert_eq!(ready.body, "ready"); - - let status = http_get(bind_addr, "/status").await?; - let body: Value = serde_json::from_str(status.body.as_str())?; - assert_eq!(body["status"], "degraded"); - assert_eq!(body["ready"], true); - assert_eq!(body["transport"]["available_relay_count"], 1); - assert_eq!(body["transport"]["unavailable_relay_count"], 1); - - let _ = shutdown_tx.send(()); - Ok(()) -} diff --git a/tests/services_hardening_discovery_state.rs b/tests/services_hardening_discovery_state.rs @@ -3,11 +3,6 @@ use std::{error::Error, fs, os::unix::fs::PermissionsExt, path::Path}; -use myc::host_identity::RadrootsIdentity; -use myc::nostr_contract::{ - RadrootsNostrApplicationHandlerSpec, RadrootsNostrMetadata, RadrootsNostrTimestamp, - radroots_nostr_build_application_handler_event, -}; use myc::{ MYC_DISCOVERY_DOCUMENT_MAX_BYTES, MYC_STATE_SCHEMA_VERSION, MycConfigProfile, MycDeliveryAttemptNonce, MycDeliveryAttemptOutcome, MycDeliveryClaim, MycDeliveryJobStatus, @@ -17,6 +12,12 @@ use myc::{ initialize_myc_state, open_myc_state_read_write, parse_myc_cli_v1_from, parse_myc_config_v1, resolve_myc_runtime_context, }; +use nostr::{Keys, SecretKey}; +use radroots_nostr::event::{ + ApplicationHandlerSpec as RadrootsNostrApplicationHandlerSpec, + Metadata as RadrootsNostrMetadata, Timestamp as RadrootsNostrTimestamp, + build_application_handler as radroots_nostr_build_application_handler_event, +}; use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity}; use radroots_storage::event::SourceGeneration; use sqlx::{ConnectOptions, Connection, sqlite::SqliteConnectOptions}; @@ -54,17 +55,17 @@ fn prepare_state_directory(runtime: &myc::MycRuntimeContext) { fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).expect("state mode"); } -fn discovery_identity() -> RadrootsIdentity { - RadrootsIdentity::from_secret_key_str(DISCOVERY_SECRET).expect("discovery identity") +fn discovery_keys() -> Keys { + Keys::new(SecretKey::parse(DISCOVERY_SECRET).expect("discovery secret")) } fn config_source(enabled: bool) -> Vec<u8> { - let identity = discovery_identity(); + let identity = discovery_keys(); let source = String::from_utf8(CONFIG_EXAMPLE.to_vec()) .expect("UTF-8 configuration") .replace( "3333333333333333333333333333333333333333333333333333333333333333", - &identity.public_key_hex(), + &identity.public_key().to_hex(), ); if enabled { return source.into_bytes(); @@ -157,7 +158,7 @@ fn signed_handler_event(created_at: u64) -> Vec<u8> { let event = radroots_nostr_build_application_handler_event(&spec) .expect("typed handler event") .custom_created_at(RadrootsNostrTimestamp::from_secs(created_at)) - .sign_with_keys(discovery_identity().keys()) + .sign_with_keys(&discovery_keys()) .expect("signed event"); serde_json::to_vec(&event).expect("canonical event bytes") } diff --git a/tests/services_hardening_legacy_removal.rs b/tests/services_hardening_legacy_removal.rs @@ -5,10 +5,18 @@ use std::process::Command; const LIB_SOURCE: &str = include_str!("../src/lib.rs"); const MAIN_SOURCE: &str = include_str!("../src/main.rs"); -const CONFIG_SOURCE: &str = include_str!("../src/config.rs"); -const PATHS_SOURCE: &str = include_str!("../src/paths.rs"); -const LOGGING_SOURCE: &str = include_str!("../src/logging.rs"); -const PERSISTENCE_SOURCE: &str = include_str!("../src/persistence.rs"); +const ACTIVE_STATE_SOURCES: &[&str] = &[ + include_str!("../src/state_catalog.rs"), + include_str!("../src/state_connection.rs"), + include_str!("../src/state_delivery.rs"), + include_str!("../src/state_discovery.rs"), + include_str!("../src/state_governance.rs"), + include_str!("../src/state_host.rs"), + include_str!("../src/state_maintenance.rs"), + include_str!("../src/state_metadata.rs"), + include_str!("../src/state_repository.rs"), + include_str!("../src/state_request.rs"), +]; #[test] fn prototype_environment_and_cli_sources_are_absent() { @@ -21,6 +29,39 @@ fn prototype_environment_and_cli_sources_are_absent() { "tests/logging_run.rs", "tests/operability_cli.rs", "tests/persistence_cli.rs", + "tests/nip46_e2e.rs", + "tests/operability_e2e.rs", + "tests/operability_server.rs", + "src/app/mod.rs", + "src/app/backend.rs", + "src/app/runtime.rs", + "src/audit.rs", + "src/audit_sqlite.rs", + "src/config.rs", + "src/control.rs", + "src/discovery.rs", + "src/operability/mod.rs", + "src/operability/server.rs", + "src/outbox.rs", + "src/outbox_sqlite.rs", + "src/paths.rs", + "src/persistence.rs", + "src/sql.rs", + "src/signer/migrations.rs", + "src/signer/sqlite.rs", + "src/signer/store.rs", + "src/transport.rs", + "src/transport/nip46.rs", + "migrations/0000_delivery_outbox_init.up.sql", + "migrations/0000_delivery_outbox_init.down.sql", + "migrations/0000_runtime_audit_init.up.sql", + "migrations/0000_runtime_audit_init.down.sql", + "migrations/signer/0000_init.up.sql", + "migrations/signer/0000_init.down.sql", + "migrations/signer/0001_publish_workflows.up.sql", + "migrations/signer/0001_publish_workflows.down.sql", + "migrations/signer/0002_client_metadata.up.sql", + "migrations/signer/0002_client_metadata.down.sql", ] { assert!( !root.join(relative).exists(), @@ -28,14 +69,6 @@ fn prototype_environment_and_cli_sources_are_absent() { ); } - let governed_sources = [ - LIB_SOURCE, - CONFIG_SOURCE, - PATHS_SOURCE, - LOGGING_SOURCE, - PERSISTENCE_SOURCE, - ] - .join("\n"); for forbidden in [ "pub mod cli;", "run_from_env", @@ -49,15 +82,59 @@ fn prototype_environment_and_cli_sources_are_absent() { "path_selection_from_entries", "parse_path_profile_env", "\"MYC_", + "pub mod app;", + "pub mod audit;", + "mod audit_sqlite;", + "pub mod config;", + "pub mod outbox;", + "mod outbox_sqlite;", + "pub mod persistence;", + "pub mod sql;", + "pub mod transport;", + "pub mod operability;", + "MycSignerStateBackend", + "MycRuntimeAuditBackend", + "import_json_to_sqlite", + "MycJsonlOperationAuditStore", + "MycSqliteOperationAuditStore", + "MycSqliteDeliveryOutboxStore", + "RadrootsNostrFileSignerStore", + "RadrootsNostrSqliteSignerStore", ] { assert!( - !governed_sources.contains(forbidden), + !LIB_SOURCE.contains(forbidden), "legacy selector remains: {forbidden}" ); } } #[test] +fn active_state_tree_has_one_shared_database_and_no_legacy_backend() { + assert_eq!(LIB_SOURCE.matches("mod state_").count(), 10); + assert!(!LIB_SOURCE.contains("pub mod state_")); + let active_state = ACTIVE_STATE_SOURCES.join("\n"); + for forbidden in [ + "signer-state.json", + "signer-state.sqlite", + "operations.jsonl", + "operations.sqlite", + "delivery-outbox.sqlite", + "manifest.json", + "backend selection", + "SqlitePool", + "Pool<Sqlite>", + "import_json_to_sqlite", + "MycSignerStateBackend", + "MycRuntimeAuditBackend", + ] { + assert!( + !active_state.contains(forbidden), + "legacy state authority remains: {forbidden}" + ); + } +} + +#[test] fn binary_uses_only_the_hardened_parser_and_fails_closed_before_dispatch() { assert!(MAIN_SOURCE.contains("parse_myc_cli_v1_from(std::env::args_os())")); assert!(!MAIN_SOURCE.contains("MycConfig")); diff --git a/tests/services_hardening_runtime_context.rs b/tests/services_hardening_runtime_context.rs @@ -4,15 +4,13 @@ use std::error::Error; use std::path::{Path, PathBuf}; use myc::{ - MycBootstrapProfileV1, MycConfig, MycRuntimeContextErrorKind, RadrootsHostEnvironment, + MycBootstrapProfileV1, MycRuntimeContextErrorKind, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, RadrootsPlatform, RuntimeContextSource, parse_myc_cli_v1_from, resolve_myc_runtime_context, }; const MANIFEST: &str = include_str!("../Cargo.toml"); const LIB_SOURCE: &str = include_str!("../src/lib.rs"); -const PATHS_SOURCE: &str = include_str!("../src/paths.rs"); -const CONFIG_SOURCE: &str = include_str!("../src/config.rs"); const CONTEXT_SOURCE: &str = include_str!("../src/runtime_context.rs"); fn resolve( @@ -281,12 +279,10 @@ fn unsupported_profile_platform_and_diagnostics_fail_safely() { Some("/private/secret-root"), Some("/private/secret-config.toml"), ); - let config = MycConfig::from_runtime_context(context.clone()); - for debug in [format!("{context:?}"), format!("{:?}", config.paths())] { - assert!(!debug.contains("secret-instance")); - assert!(!debug.contains("secret-root")); - assert!(!debug.contains("secret-config")); - } + let debug = format!("{context:?}"); + assert!(!debug.contains("secret-instance")); + assert!(!debug.contains("secret-root")); + assert!(!debug.contains("secret-config")); } #[test] @@ -295,34 +291,10 @@ fn shared_runtime_paths_are_the_only_path_policy_and_identity_authority() { "radroots_runtime_paths = { git = \"https://github.com/radrootslabs/lib\", rev = \"b44119fbac5985be8127ad1bf56d2950e6399427\", version = \"=0.1.0-alpha\" }" )); assert!(LIB_SOURCE.contains("mod runtime_context;")); - assert!(LIB_SOURCE.contains("mod paths;")); assert!(!LIB_SOURCE.contains("pub mod runtime_context;")); - assert!(!LIB_SOURCE.contains("pub mod paths;")); assert!(CONTEXT_SOURCE.contains("RuntimeContext::resolve(")); assert!(CONTEXT_SOURCE.contains("default_service_instance_artifacts(")); - - for forbidden in [ - "struct RadrootsHostEnvironment", - "enum RadrootsPlatform", - "enum RadrootsPathProfile", - "struct RadrootsPathResolver", - "struct RadrootsRuntimePathSelection", - "struct RuntimeRoots", - "std::env::", - "var_os(", - "worker_path", - "worker_namespace", - "apply_path_defaults", - "default_with_path_selection", - ] { - assert!(!PATHS_SOURCE.contains(forbidden), "found `{forbidden}`"); - } - for forbidden in [ - "impl Default for MycConfig", - "struct MycServiceConfig", - "service.instance_name", - "MYC_INSTANCE_ID_MAX_BYTES", - ] { - assert!(!CONFIG_SOURCE.contains(forbidden), "found `{forbidden}`"); - } + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + assert!(!root.join("src/paths.rs").exists()); + assert!(!root.join("src/config.rs").exists()); } diff --git a/tests/services_hardening_signer_request_state.rs b/tests/services_hardening_signer_request_state.rs @@ -458,7 +458,7 @@ async fn concurrent_identical_admission_creates_one_request_and_bounded_replay_e } #[tokio::test] -async fn exact_schema_v3_state_advances_to_v5_before_request_admission() { +async fn exact_schema_v3_state_advances_to_v7_before_request_admission() { let directory = tempfile::tempdir().expect("temporary root"); let runtime = runtime(directory.path()); prepare_state_directory(&runtime); @@ -513,6 +513,12 @@ async fn exact_schema_v3_state_advances_to_v5_before_request_admission() { "DROP TRIGGER connection_permissions_no_update", "DROP TRIGGER connections_no_delete", "DROP TRIGGER connections_guard_update", + "DROP TABLE discovery_publication_state", + "DROP TABLE discovery_documents", + "DROP TABLE discovery_desired_state", + "DROP TABLE delivery_attempts", + "DROP TABLE delivery_targets", + "DROP TABLE delivery_jobs", "DROP TABLE nip46_request_audit", "DROP TABLE operation_audit", "DROP TABLE connection_rate_windows", @@ -523,7 +529,7 @@ async fn exact_schema_v3_state_advances_to_v5_before_request_admission() { "DROP TABLE connections", "UPDATE radroots_service_metadata SET state_schema_version = 3 WHERE singleton = 1", "UPDATE myc_state_metadata SET state_contract_version = 3 WHERE singleton = 1", - "DELETE FROM schema_migrations WHERE version IN (4, 5)", + "DELETE FROM schema_migrations WHERE version IN (4, 5, 6, 7)", ] { sqlx::query(sql) .execute(&mut connection) diff --git a/tests/services_hardening_state_repository.rs b/tests/services_hardening_state_repository.rs @@ -119,7 +119,7 @@ async fn initialization_migrates_and_binds_exact_metadata_before_inspection() { .fetch_all(&mut connection) .await .expect("migration rows"); - assert_eq!(migrations.len(), 5); + assert_eq!(migrations.len(), 6); assert_eq!(migrations[0].get::<i64, _>(0), 2); assert_eq!( migrations[0].get::<String, _>(1), @@ -145,6 +145,11 @@ async fn initialization_migrates_and_binds_exact_metadata_before_inspection() { migrations[4].get::<String, _>(1), "create_delivery_evidence_state" ); + assert_eq!(migrations[5].get::<i64, _>(0), 7); + assert_eq!( + migrations[5].get::<String, _>(1), + "create_discovery_desired_state" + ); let binding = sqlx::query( "SELECT normalized_config_sha256, transport_public_key, user_public_key, \ discovery_public_key, config_contract_version, state_contract_version, \ diff --git a/tests/support/mod.rs b/tests/support/mod.rs @@ -1,25 +0,0 @@ -use std::{ffi::OsString, path::Path}; - -use myc::{ - MycConfig, RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, - parse_myc_cli_v1_from, resolve_myc_runtime_context, -}; - -pub fn repo_local_config(root: &Path) -> MycConfig { - let invocation = parse_myc_cli_v1_from(vec![ - OsString::from("myc"), - OsString::from("--profile"), - OsString::from("repo-local"), - OsString::from("--instance"), - OsString::from("test"), - OsString::from("--repo-local-root"), - root.as_os_str().to_owned(), - OsString::from("run"), - ]) - .expect("test CLI selection"); - let resolver = - RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); - let context = - resolve_myc_runtime_context(&resolver, &invocation).expect("test runtime context"); - MycConfig::from_runtime_context(context) -}