commit 069ccb703a03a042ea13170e8076ca06c8cfa85d
parent 961787013babf953e8dbdf2dc059a94081b0fc8d
Author: triesap <tyson@radroots.org>
Date: Fri, 21 Aug 2026 19:40:01 +0000
state: remove prototype persistence
Diffstat:
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,
- ¤t_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",
- ¤t_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(¤t_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(¬ify);
-
- 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, ¬ify).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)
-}