lib

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

commit 34dc13cf36e883a143adb3f2be164b68a49cc50c
parent 6693cf3f2c4a138a68db2c145ac9e951b9788dbd
Author: triesap <tyson@radroots.org>
Date:   Fri, 17 Jul 2026 21:41:25 +0000

nostr_signer: persist NIP-46 client metadata

- carry normalized display hints through connect evaluation
- preserve optional metadata in JSON and SQLite stores
- add a reversible forward migration for existing databases
- make explicit connection revocation idempotent

Diffstat:
Mcrates/nostr_connect/src/uri.rs | 31++++++++++++++++++++++++++++++-
Acrates/nostr_signer/migrations/0002_client_metadata.down.sql | 2++
Acrates/nostr_signer/migrations/0002_client_metadata.up.sql | 2++
Mcrates/nostr_signer/src/evaluation.rs | 23+++++++++++++++++++++--
Mcrates/nostr_signer/src/manager.rs | 81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/nostr_signer/src/migrations.rs | 5+++++
Mcrates/nostr_signer/src/model.rs | 26++++++++++++++++++++++++--
Mcrates/nostr_signer/src/sqlite.rs | 9+++++++--
Mcrates/nostr_signer/src/store.rs | 29++++++++++++++++++++++++-----
9 files changed, 185 insertions(+), 23 deletions(-)

diff --git a/crates/nostr_connect/src/uri.rs b/crates/nostr_connect/src/uri.rs @@ -20,7 +20,7 @@ pub struct RadrootsNostrConnectBunkerUri { pub secret: Option<String>, } -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct RadrootsNostrConnectClientMetadata { #[serde( default, @@ -35,6 +35,18 @@ pub struct RadrootsNostrConnectClientMetadata { pub image: Option<String>, } +#[derive(Debug, Deserialize)] +struct RadrootsNostrConnectClientMetadataSerde { + #[serde(default)] + requested_permissions: RadrootsNostrConnectPermissions, + #[serde(default)] + name: Option<String>, + #[serde(default)] + url: Option<String>, + #[serde(default)] + image: Option<String>, +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] struct RadrootsNostrConnectClientMetadataWire { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -99,6 +111,23 @@ impl RadrootsNostrConnectClientMetadata { } } +impl<'de> Deserialize<'de> for RadrootsNostrConnectClientMetadata { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let metadata = RadrootsNostrConnectClientMetadataSerde::deserialize(deserializer)?; + Self { + requested_permissions: metadata.requested_permissions, + name: metadata.name, + url: metadata.url, + image: metadata.image, + } + .normalized() + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RadrootsNostrConnectClientUri { pub client_public_key: PublicKey, diff --git a/crates/nostr_signer/migrations/0002_client_metadata.down.sql b/crates/nostr_signer/migrations/0002_client_metadata.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE signer_connection +DROP COLUMN client_metadata_json; diff --git a/crates/nostr_signer/migrations/0002_client_metadata.up.sql b/crates/nostr_signer/migrations/0002_client_metadata.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE signer_connection +ADD COLUMN client_metadata_json TEXT; diff --git a/crates/nostr_signer/src/evaluation.rs b/crates/nostr_signer/src/evaluation.rs @@ -7,8 +7,9 @@ use crate::model::{ use nostr::{PublicKey, RelayUrl}; use radroots_identity::RadrootsIdentityPublic; use radroots_nostr_connect::prelude::{ - RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, - RadrootsNostrConnectRemoteSessionCapability, RadrootsNostrConnectRequest, + RadrootsNostrConnectClientMetadata, RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, + RadrootsNostrConnectPermissions, RadrootsNostrConnectRemoteSessionCapability, + RadrootsNostrConnectRequest, }; #[derive(Debug, Clone)] @@ -22,6 +23,7 @@ pub enum RadrootsNostrSignerSessionLookup { pub struct RadrootsNostrSignerConnectProposal { pub client_public_key: PublicKey, pub connect_secret: Option<String>, + pub client_metadata: Option<RadrootsNostrConnectClientMetadata>, pub requested_permissions: RadrootsNostrConnectPermissions, } @@ -75,6 +77,9 @@ impl RadrootsNostrSignerConnectProposal { if let Some(connect_secret) = self.connect_secret { draft = draft.with_connect_secret(connect_secret); } + if let Some(client_metadata) = self.client_metadata { + draft = draft.with_client_metadata(client_metadata); + } draft } } @@ -317,6 +322,12 @@ mod tests { let proposal = RadrootsNostrSignerConnectProposal { client_public_key: public_key(5), connect_secret: Some("secret".into()), + client_metadata: Some(RadrootsNostrConnectClientMetadata { + requested_permissions: RadrootsNostrConnectPermissions::default(), + name: Some("Example Client".into()), + url: Some("https://client.example.com/".into()), + image: None, + }), requested_permissions: requested_permissions.clone(), }; @@ -324,10 +335,18 @@ mod tests { assert_eq!(draft.connect_secret.as_deref(), Some("secret")); assert_eq!(draft.requested_permissions, requested_permissions); + assert_eq!( + draft + .client_metadata + .as_ref() + .and_then(|metadata| metadata.name.as_deref()), + Some("Example Client") + ); let no_secret = RadrootsNostrSignerConnectProposal { client_public_key: public_key(7), connect_secret: None, + client_metadata: None, requested_permissions: RadrootsNostrConnectPermissions::default(), } .into_connection_draft(fixture_bob_identity()); diff --git a/crates/nostr_signer/src/manager.rs b/crates/nostr_signer/src/manager.rs @@ -21,7 +21,8 @@ use crate::store::{RadrootsNostrMemorySignerStore, RadrootsNostrSignerStore}; use nostr::{PublicKey, RelayUrl}; use radroots_identity::RadrootsIdentityPublic; use radroots_nostr_connect::prelude::{ - RadrootsNostrConnectMethod, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest, + RadrootsNostrConnectClientMetadata, RadrootsNostrConnectMethod, + RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, }; use std::sync::{Arc, RwLock}; @@ -203,7 +204,7 @@ impl RadrootsNostrSignerManager { remote_signer_public_key, secret, requested_permissions, - client_metadata: _, + client_metadata, } = request else { return Err(RadrootsNostrSignerError::InvalidState( @@ -228,6 +229,7 @@ impl RadrootsNostrSignerManager { RadrootsNostrSignerConnectProposal { client_public_key, connect_secret, + client_metadata: client_metadata.map(normalize_client_metadata).transpose()?, requested_permissions: normalize_permissions(requested_permissions), }, )) @@ -303,6 +305,10 @@ impl RadrootsNostrSignerManager { client_public_key: draft.client_public_key, user_identity: draft.user_identity, connect_secret: draft.connect_secret, + client_metadata: draft + .client_metadata + .map(normalize_client_metadata) + .transpose()?, requested_permissions: normalize_permissions(draft.requested_permissions), relays: normalize_relays(draft.relays), approval_requirement: draft.approval_requirement, @@ -414,9 +420,7 @@ impl RadrootsNostrSignerManager { let updated_at_unix = now_unix_secs(); let record = find_connection_mut(state, connection_id)?; if record.status == RadrootsNostrSignerConnectionStatus::Revoked { - return Err(RadrootsNostrSignerError::InvalidState( - "connection already revoked".into(), - )); + return Ok(record.clone()); } record.status = RadrootsNostrSignerConnectionStatus::Revoked; @@ -1108,6 +1112,13 @@ fn normalize_permissions( permissions.into() } +fn normalize_client_metadata( + mut metadata: RadrootsNostrConnectClientMetadata, +) -> Result<RadrootsNostrConnectClientMetadata, RadrootsNostrSignerError> { + metadata.requested_permissions = RadrootsNostrConnectPermissions::default(); + Ok(metadata.normalized()?) +} + fn normalize_relays(relays: Vec<RelayUrl>) -> Vec<RelayUrl> { let mut relays = relays; relays.sort_by(|left, right| left.as_str().cmp(right.as_str())); @@ -1526,6 +1537,40 @@ mod tests { } #[test] + fn register_connection_normalizes_display_only_client_metadata() { + let manager = RadrootsNostrSignerManager::new_in_memory(); + manager + .set_signer_identity(fixture_alice_identity()) + .expect("set signer identity"); + let requested_permissions = vec![permission(RadrootsNostrConnectMethod::Ping, None)].into(); + let record = manager + .register_connection( + RadrootsNostrSignerConnectionDraft::new(public_key(0x90), public_identity(0x91)) + .with_requested_permissions(requested_permissions) + .with_client_metadata(RadrootsNostrConnectClientMetadata { + requested_permissions: vec![permission( + RadrootsNostrConnectMethod::Nip44Encrypt, + None, + )] + .into(), + name: Some(" Example Client ".into()), + url: Some("https://client.example.com".into()), + image: None, + }), + ) + .expect("register metadata connection"); + + let metadata = record.client_metadata.expect("stored client metadata"); + assert_eq!(metadata.name.as_deref(), Some("Example Client")); + assert_eq!(metadata.url.as_deref(), Some("https://client.example.com/")); + assert!(metadata.requested_permissions.is_empty()); + assert_eq!( + record.requested_permissions.as_slice(), + &[permission(RadrootsNostrConnectMethod::Ping, None)] + ); + } + + #[test] fn register_connection_enforces_identity_and_uniqueness_rules() { let manager = RadrootsNostrSignerManager::new_in_memory(); manager @@ -1805,12 +1850,13 @@ mod tests { let revoke_again = manager .revoke_connection(&active.connection_id, None) - .expect_err("revoke twice"); - assert!( - revoke_again - .to_string() - .contains("connection already revoked") + .expect("revoke twice idempotently"); + assert_eq!( + revoke_again.status, + RadrootsNostrSignerConnectionStatus::Revoked ); + assert_eq!(revoke_again.status_reason.as_deref(), Some("manual")); + assert_eq!(revoke_again.updated_at_unix, revoked.updated_at_unix); let grants_err = manager .set_granted_permissions( @@ -3766,13 +3812,26 @@ mod tests { permission(RadrootsNostrConnectMethod::Ping, None), ] .into(), - client_metadata: None, + client_metadata: Some(RadrootsNostrConnectClientMetadata { + requested_permissions: vec![permission( + RadrootsNostrConnectMethod::Nip44Encrypt, + None, + )] + .into(), + name: Some(" Example Client ".into()), + url: Some("https://client.example.com".into()), + image: None, + }), }, ) .expect("registration connect request"); let proposal = expect_registration_connect(registration_connect); assert_eq!(proposal.client_public_key, public_key(0x67)); assert_eq!(proposal.connect_secret.as_deref(), Some("fresh-secret")); + let metadata = proposal.client_metadata.as_ref().expect("client metadata"); + assert_eq!(metadata.name.as_deref(), Some("Example Client")); + assert_eq!(metadata.url.as_deref(), Some("https://client.example.com/")); + assert!(metadata.requested_permissions.is_empty()); assert_eq!( proposal.requested_permissions.as_slice(), &[ diff --git a/crates/nostr_signer/src/migrations.rs b/crates/nostr_signer/src/migrations.rs @@ -17,6 +17,11 @@ pub static MIGRATIONS: &[Migration] = &[ up_sql: include_str!("../migrations/0001_publish_workflows.up.sql"), down_sql: include_str!("../migrations/0001_publish_workflows.down.sql"), }, + Migration { + name: "0002_client_metadata", + up_sql: include_str!("../migrations/0002_client_metadata.up.sql"), + down_sql: include_str!("../migrations/0002_client_metadata.down.sql"), + }, ]; #[cfg(feature = "native")] diff --git a/crates/nostr_signer/src/model.rs b/crates/nostr_signer/src/model.rs @@ -3,8 +3,8 @@ use hex::encode as hex_encode; use nostr::{PublicKey, RelayUrl}; use radroots_identity::RadrootsIdentityPublic; use radroots_nostr_connect::prelude::{ - RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, - RadrootsNostrConnectRequestMessage, + RadrootsNostrConnectClientMetadata, RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, + RadrootsNostrConnectPermissions, RadrootsNostrConnectRequestMessage, }; use serde::{Deserialize, Deserializer, Serialize}; use sha2::{Digest, Sha256}; @@ -122,6 +122,7 @@ pub struct RadrootsNostrSignerConnectionDraft { pub client_public_key: PublicKey, pub user_identity: RadrootsIdentityPublic, pub connect_secret: Option<String>, + pub client_metadata: Option<RadrootsNostrConnectClientMetadata>, pub requested_permissions: RadrootsNostrConnectPermissions, pub relays: Vec<RelayUrl>, pub approval_requirement: RadrootsNostrSignerApprovalRequirement, @@ -142,6 +143,8 @@ pub struct RadrootsNostrSignerConnectionRecord { pub connect_secret_hash: Option<RadrootsNostrSignerConnectSecretHash>, #[serde(default, skip_serializing_if = "Option::is_none")] pub connect_secret_consumed_at_unix: Option<u64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_metadata: Option<RadrootsNostrConnectClientMetadata>, pub requested_permissions: RadrootsNostrConnectPermissions, #[serde(default)] pub granted_permissions: Vec<RadrootsNostrSignerPermissionGrant>, @@ -458,6 +461,7 @@ impl RadrootsNostrSignerConnectionDraft { client_public_key, user_identity, connect_secret: None, + client_metadata: None, requested_permissions: RadrootsNostrConnectPermissions::default(), relays: Vec::new(), approval_requirement: RadrootsNostrSignerApprovalRequirement::NotRequired, @@ -477,6 +481,14 @@ impl RadrootsNostrSignerConnectionDraft { self } + pub fn with_client_metadata( + mut self, + client_metadata: RadrootsNostrConnectClientMetadata, + ) -> Self { + self.client_metadata = Some(client_metadata); + self + } + pub fn with_relays(mut self, relays: Vec<RelayUrl>) -> Self { self.relays = relays; self @@ -519,6 +531,7 @@ impl RadrootsNostrSignerConnectionRecord { .as_deref() .and_then(RadrootsNostrSignerConnectSecretHash::from_secret), connect_secret_consumed_at_unix: None, + client_metadata: draft.client_metadata, requested_permissions: draft.requested_permissions, granted_permissions: Vec::new(), relays: draft.relays, @@ -838,16 +851,24 @@ mod tests { "kind:1", ); let relay = primary_relay(); + let metadata = RadrootsNostrConnectClientMetadata { + requested_permissions: RadrootsNostrConnectPermissions::default(), + name: Some("Example Client".into()), + url: None, + image: None, + }; let draft = RadrootsNostrSignerConnectionDraft::new( fixture_carol_public_key(), fixture_bob_identity(), ) .with_connect_secret(" secret ") + .with_client_metadata(metadata.clone()) .with_requested_permissions(vec![permission.clone()].into()) .with_relays(vec![relay.clone()]) .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser); assert_eq!(draft.connect_secret.as_deref(), Some(" secret ")); + assert_eq!(draft.client_metadata.as_ref(), Some(&metadata)); assert_eq!(draft.requested_permissions.as_slice(), &[permission]); assert_eq!(draft.relays, vec![relay]); assert_eq!( @@ -1328,6 +1349,7 @@ mod tests { })) .expect("deserialize record without secret"); assert!(decoded_without_secret.connect_secret_hash.is_none()); + assert!(decoded_without_secret.client_metadata.is_none()); assert!( decoded_without_secret .connect_secret_consumed_at_unix diff --git a/crates/nostr_signer/src/sqlite.rs b/crates/nostr_signer/src/sqlite.rs @@ -169,7 +169,12 @@ mod tests { "SELECT COUNT(*) AS applied_count FROM __migrations", "applied_count", ); - assert_eq!(migration_count, 2); + 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, @@ -220,6 +225,6 @@ mod tests { "SELECT COUNT(*) AS applied_count FROM __migrations", "applied_count", ); - assert_eq!(migration_count, 2); + assert_eq!(migration_count, 3); } } diff --git a/crates/nostr_signer/src/store.rs b/crates/nostr_signer/src/store.rs @@ -26,7 +26,8 @@ use nostr::RelayUrl; use radroots_identity::RadrootsIdentityPublic; #[cfg(feature = "native")] use radroots_nostr_connect::prelude::{ - RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectRequestMessage, + RadrootsNostrConnectClientMetadata, RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, + RadrootsNostrConnectRequestMessage, }; #[cfg(feature = "native")] use radroots_sql_core::SqlExecutor; @@ -167,7 +168,7 @@ impl RadrootsNostrSignerStore for RadrootsNostrSqliteSignerStore { 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, 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", + "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 { @@ -312,7 +313,7 @@ impl RadrootsNostrSignerStore for RadrootsNostrSqliteSignerStore { 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, approval_requirement, approval_state, auth_state, status, status_reason, created_at_unix, updated_at_unix, last_authenticated_at_unix, last_request_at_unix) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "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(), @@ -332,6 +333,11 @@ impl RadrootsNostrSignerStore for RadrootsNostrSqliteSignerStore { .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), @@ -469,6 +475,7 @@ struct SignerConnectionRow { 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, @@ -505,6 +512,11 @@ impl SignerConnectionRow { }, 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::<RadrootsNostrConnectClientMetadata>) + .transpose()?, granted_permissions: Vec::new(), relays: Vec::new(), approval_requirement: parse_approval_requirement(self.approval_requirement.as_str())?, @@ -870,8 +882,9 @@ mod tests { }; #[cfg(feature = "native")] use radroots_nostr_connect::prelude::{ - RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectRequest, - RadrootsNostrConnectRequestMessage, + RadrootsNostrConnectClientMetadata, RadrootsNostrConnectMethod, + RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, + RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, }; use std::thread; @@ -991,6 +1004,12 @@ mod tests { signer_identity.clone(), RadrootsNostrSignerConnectionDraft::new(fixture_carol_public_key(), user_identity) .with_connect_secret("sqlite-secret") + .with_client_metadata(RadrootsNostrConnectClientMetadata { + requested_permissions: RadrootsNostrConnectPermissions::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![