feat(nip46): publish kind-0 metadata through the connected signer
This commit is contained in:
parent
327bf0ffe0
commit
6f4dbb2ca1
4 changed files with 108 additions and 4 deletions
|
|
@ -577,7 +577,7 @@ function RenameModal({
|
||||||
perform: () => renameProfile(target.npub, trimmed),
|
perform: () => renameProfile(target.npub, trimmed),
|
||||||
successMessage: (report) =>
|
successMessage: (report) =>
|
||||||
report.succeeded.length === 0 && report.failed.length === 0
|
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
|
: 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}" 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.`,
|
: `Renamed to "${trimmed}", but ${report.failed.length} relay(s) did not accept it. Use "Publish name" to retry.`,
|
||||||
|
|
|
||||||
20
src/ipc.rs
20
src/ipc.rs
|
|
@ -702,9 +702,23 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
|
||||||
}
|
}
|
||||||
|
|
||||||
Request::PublishProfileMetadata { npub } => {
|
Request::PublishProfileMetadata { npub } => {
|
||||||
let key = app.vault_key().copied();
|
// Route through the profile's Signing source, exactly like
|
||||||
let report =
|
// PublishNote: an embedded profile signs locally, a paired
|
||||||
profiles::publish_profile_metadata(&app.vault, &npub, key.as_ref(), &app.settings)?;
|
// 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))
|
Ok(json!(report))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -190,11 +190,68 @@ pub struct MetadataPublishReport {
|
||||||
pub failed: Vec<RelayFailure>,
|
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
|
/// Publish a profile's stored label (and picture, when set) as kind 0 metadata
|
||||||
/// so external clients (Iris, Yakihonne, ...) display its name. Returns a
|
/// so external clients (Iris, Yakihonne, ...) display its name. Returns a
|
||||||
/// per-relay report.
|
/// per-relay report.
|
||||||
///
|
///
|
||||||
/// `key` must be the unlocked vault key when the vault is password-protected.
|
/// `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(
|
pub fn publish_profile_metadata(
|
||||||
vault: &Vault,
|
vault: &Vault,
|
||||||
npub: &str,
|
npub: &str,
|
||||||
|
|
|
||||||
|
|
@ -648,6 +648,11 @@ async fn nip46_bunker_connect_params_match_spec_against_strict_amber() {
|
||||||
));
|
));
|
||||||
|
|
||||||
let signer = Nip46ClientSigner::new(app.clone());
|
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
|
// No secret in the URI: the strict signer must still ack a well-formed
|
||||||
// connect whose params[0] is its own pubkey.
|
// 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);
|
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();
|
signer.disconnect().await.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue