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:
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![