From 6f4dbb2ca16a159d97ae923a35014ef35ef0e7a3 Mon Sep 17 00:00:00 2001 From: Avi Date: Wed, 23 Sep 2026 15:30:35 -0500 Subject: [PATCH] feat(nip46): publish kind-0 metadata through the connected signer --- frontend/src/screens/ProfilesScreen.tsx | 2 +- src/ipc.rs | 20 +++++++-- src/profiles.rs | 57 +++++++++++++++++++++++++ tests/nip46_e2e.rs | 33 ++++++++++++++ 4 files changed, 108 insertions(+), 4 deletions(-) diff --git a/frontend/src/screens/ProfilesScreen.tsx b/frontend/src/screens/ProfilesScreen.tsx index 200700a..c66a083 100644 --- a/frontend/src/screens/ProfilesScreen.tsx +++ b/frontend/src/screens/ProfilesScreen.tsx @@ -577,7 +577,7 @@ function RenameModal({ perform: () => renameProfile(target.npub, trimmed), successMessage: (report) => report.succeeded.length === 0 && report.failed.length === 0 - ? `Renamed to "${trimmed}" (saved on this device; external-signer profiles publish their name from the signer app).` + ? `Renamed to "${trimmed}" and saved on this device. Use "Publish name" to announce it network-wide — Amber will ask you to approve.` : report.failed.length === 0 ? `Renamed to "${trimmed}" and published to ${report.succeeded.length} relay(s). It may take a minute to appear on other clients.` : `Renamed to "${trimmed}", but ${report.failed.length} relay(s) did not accept it. Use "Publish name" to retry.`, diff --git a/src/ipc.rs b/src/ipc.rs index af851c6..b465577 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -702,9 +702,23 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { - let key = app.vault_key().copied(); - let report = - profiles::publish_profile_metadata(&app.vault, &npub, key.as_ref(), &app.settings)?; + // Route through the profile's Signing source, exactly like + // PublishNote: an embedded profile signs locally, a paired + // profile's kind-0 is signed by the remote signer (Amber shows + // an approval prompt), and a disconnected one fails closed. + // The shared App guard is held across the round-trip; the + // signer's demux needs no App lock to deliver the response. + let signing = app.signing_for(&npub).await?; + let stored = profiles::find_stored_profile(&app.vault, &npub)?.clone(); + let settings = app.settings.clone(); + let report = profiles::publish_metadata_signed( + &settings, + &stored.label, + stored.picture.clone(), + stored.nip05.clone(), + &signing, + ) + .await?; Ok(json!(report)) } diff --git a/src/profiles.rs b/src/profiles.rs index 0d6e146..d5b8dfc 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -190,11 +190,68 @@ pub struct MetadataPublishReport { pub failed: Vec, } +/// Publish a profile's stored label (and picture, when set) as kind 0 metadata +/// through an explicit [`Signing`] source (embedded or external). +/// +/// This is what the GUI "Publish name" path uses: for a paired profile the +/// kind-0 event is signed by the remote signer (Amber shows an approval +/// prompt), so the name becomes visible network-wide instead of staying a +/// local vault label. A disconnected external profile fails closed with +/// `ExternalSignerNotConnected` — never with a silent local-key fallback. +pub async fn publish_metadata_signed( + settings: &Settings, + label: &str, + picture: Option, + nip05: Option, + signing: &crate::signer::Signing, +) -> Result { + let relay_urls = relays::enabled_urls(settings); + if relay_urls.is_empty() { + return Err(AppError::no_enabled_relays()); + } + let mut metadata = Metadata::new().name(label).display_name(label); + if let Some(picture) = &picture { + if let Ok(parsed) = Url::parse(picture) { + metadata = metadata.picture(parsed); + } + } + if let Some(nip05) = &nip05 { + metadata = metadata.nip05(nip05); + } + // Identity first (validates the signer controls this profile), then sign + // through `Signing` — local key or the NIP-46 round-trip. + let pubkey = signing.pubkey().await.map_err(AppError::from)?; + let unsigned = EventBuilder::new(Kind::Metadata, metadata.as_json()).finalize_unsigned(pubkey); + let signed = signing + .sign(unsigned) + .await + .map_err(|e| AppError::sign_failed(format!("{e}")))?; + + // Local signing can answer NIP-42 AUTH challenges; with an external + // signer the app holds no key, so the pool opens without an + // authenticator and auth-gated relays report per-relay. + let client = match signing { + crate::signer::Signing::Local(keys) => { + relays::open_pool(keys.clone(), &relay_urls, None).await? + } + crate::signer::Signing::External { .. } => { + relays::open_pool_anon(&relay_urls, None).await? + } + }; + let (succeeded, failed) = + crate::publish::send_to_all_relays(&client, relay_urls, &signed, "metadata").await; + Ok(MetadataPublishReport { succeeded, failed }) +} + /// Publish a profile's stored label (and picture, when set) as kind 0 metadata /// so external clients (Iris, Yakihonne, ...) display its name. Returns a /// per-relay report. /// /// `key` must be the unlocked vault key when the vault is password-protected. +/// +/// Local-only: always signs from the vault. The CLI uses this (it has no +/// signer instances); GUI callers use [`publish_metadata_signed`] with an +/// [`App::signing_for`] source so paired profiles sign remotely. pub fn publish_profile_metadata( vault: &Vault, npub: &str, diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs index cffd012..68e6a9a 100644 --- a/tests/nip46_e2e.rs +++ b/tests/nip46_e2e.rs @@ -648,6 +648,11 @@ async fn nip46_bunker_connect_params_match_spec_against_strict_amber() { )); let signer = Nip46ClientSigner::new(app.clone()); + // Register the handle on the App exactly like production's + // `ensure_nip46_signer` does: `App::signing_for` (used below for the + // kind-0 publish) resolves the live session through this handle. + // `Nip46ClientSigner::clone` shares the session state. + app.lock().await.nip46_signer = Some(std::sync::Arc::new(signer.clone())); // No secret in the URI: the strict signer must still ack a well-formed // connect whose params[0] is its own pubkey. @@ -705,6 +710,34 @@ async fn nip46_bunker_connect_params_match_spec_against_strict_amber() { ); drop(app_guard); + // Kind-0 through the remote signer: the vault label becomes a signed + // network-visible profile (what other clients display as the name). + // Exercises the real GUI "Publish name" path — Signing::External with + // identity validation — against the strict signer. + let (settings, signing) = { + let guard = app.lock().await; + ( + guard.settings.clone(), + guard + .signing_for(&identity_npub) + .await + .expect("signing source for the paired profile"), + ) + }; + let report = keynectr::profiles::publish_metadata_signed( + &settings, + "Strict Amber", + None, + None, + &signing, + ) + .await + .expect("remote kind-0 publish"); + assert!( + !report.succeeded.is_empty(), + "at least one relay must accept the signed kind-0" + ); + signer.disconnect().await.ok(); }