feat(nip46): publish kind-0 metadata through the connected signer

This commit is contained in:
Avi 2026-09-23 15:30:35 -05:00
commit 6f4dbb2ca1
4 changed files with 108 additions and 4 deletions

View file

@ -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.`,

View file

@ -702,9 +702,23 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
}
Request::PublishProfileMetadata { npub } => {
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))
}

View file

@ -190,11 +190,68 @@ pub struct MetadataPublishReport {
pub failed: Vec<RelayFailure>,
}
/// 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<String>,
nip05: Option<String>,
signing: &crate::signer::Signing,
) -> Result<MetadataPublishReport, AppError> {
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,

View file

@ -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();
}