diff --git a/core/archipelago/src/api/rpc/handshake.rs b/core/archipelago/src/api/rpc/handshake.rs index b5499774..aacf7493 100644 --- a/core/archipelago/src/api/rpc/handshake.rs +++ b/core/archipelago/src/api/rpc/handshake.rs @@ -32,6 +32,9 @@ use crate::nostr_handshake::DISCOVERY_STATE_FILE as NOSTR_STATE_FILE; struct NostrDiscoveryState { #[serde(default)] enabled: bool, + /// Operator-chosen display name carried in the presence event. + #[serde(default, skip_serializing_if = "Option::is_none")] + name: Option, } async fn load_discovery_state(data_dir: &std::path::Path) -> NostrDiscoveryState { @@ -64,7 +67,7 @@ impl RpcHandler { let npub = nostr_handshake::own_npub(&self.config.data_dir.join("identity")) .await .unwrap_or(None); - Ok(serde_json::json!({ "enabled": state.enabled, "npub": npub })) + Ok(serde_json::json!({ "enabled": state.enabled, "npub": npub, "name": state.name })) } /// Set the runtime discoverability flag. If turning ON, publish presence @@ -84,7 +87,24 @@ impl RpcHandler { .and_then(|v| v.as_bool()) .ok_or_else(|| anyhow::anyhow!("Missing enabled"))?; - save_discovery_state(&self.config.data_dir, &NostrDiscoveryState { enabled }).await?; + // Optional display name. Absent param = keep the stored name (so a + // plain off/on toggle doesn't forget it); present-but-empty clears it. + let prior = load_discovery_state(&self.config.data_dir).await; + let name = match params.get("name") { + Some(v) => v + .as_str() + .and_then(nostr_handshake::clean_display_name), + None => prior.name, + }; + + save_discovery_state( + &self.config.data_dir, + &NostrDiscoveryState { + enabled, + name: name.clone(), + }, + ) + .await?; if enabled && !self.config.nostr_relays.is_empty() { let (data, _) = self.state_manager.get_snapshot().await; @@ -94,11 +114,13 @@ impl RpcHandler { let version = data.server_info.version.clone(); let relays = self.handshake_relays().await; let tor_proxy = self.config.nostr_tor_proxy.clone(); + let publish_name = name.clone(); tokio::spawn(async move { if let Err(e) = nostr_handshake::publish_presence( &identity_dir, &did, &version, + publish_name.as_deref(), &relays, tor_proxy.as_deref(), ) diff --git a/core/archipelago/src/nostr_handshake.rs b/core/archipelago/src/nostr_handshake.rs index 75375866..0466f123 100644 --- a/core/archipelago/src/nostr_handshake.rs +++ b/core/archipelago/src/nostr_handshake.rs @@ -47,14 +47,36 @@ pub const DISCOVERY_STATE_FILE: &str = "nostr_discovery_state.json"; /// tolerates three misses. pub const PRESENCE_TTL_SECS: u64 = 48 * 3600; -/// Read the runtime discovery override. `None` means the toggle has never -/// been used on this node — callers fall back to the config flag. -pub async fn discovery_enabled_override(data_dir: &Path) -> Option { - let raw = fs::read_to_string(data_dir.join(DISCOVERY_STATE_FILE)) - .await - .ok()?; - let v: serde_json::Value = serde_json::from_str(&raw).ok()?; - v.get("enabled").and_then(|e| e.as_bool()) +/// Read the runtime discovery override and the operator-chosen display name. +/// Enabled `None` means the toggle has never been used on this node — +/// callers fall back to the config flag. +pub async fn discovery_overrides(data_dir: &Path) -> (Option, Option) { + let Ok(raw) = fs::read_to_string(data_dir.join(DISCOVERY_STATE_FILE)).await else { + return (None, None); + }; + let Ok(v) = serde_json::from_str::(&raw) else { + return (None, None); + }; + let enabled = v.get("enabled").and_then(|e| e.as_bool()); + let name = v + .get("name") + .and_then(|n| n.as_str()) + .and_then(clean_display_name); + (enabled, name) +} + +/// Display names travel in a PUBLIC relay event and come back from untrusted +/// peers — normalise both directions: single line, control chars stripped, +/// hard length cap, empty collapses to None. +pub fn clean_display_name(raw: &str) -> Option { + let cleaned: String = raw + .chars() + .filter(|c| !c.is_control()) + .take(32) + .collect::() + .trim() + .to_string(); + (!cleaned.is_empty()).then_some(cleaned) } /// This node's own published npub (bech32), if discovery keys exist. @@ -161,6 +183,7 @@ pub async fn publish_presence( identity_dir: &Path, did: &str, version: &str, + name: Option<&str>, relays: &[String], tor_proxy: Option<&str>, ) -> Result<()> { @@ -176,14 +199,20 @@ pub async fn publish_presence( let nostr_npub = keys.public_key().to_bech32().unwrap_or_default(); let client = build_client(keys, tor_proxy)?; - let content = serde_json::json!({ + let mut fields = serde_json::json!({ "did": did, "nostr_pubkey": nostr_pubkey, "nostr_npub": nostr_npub, "version": version, // No onion address — exchanged only via encrypted DM - }) - .to_string(); + }); + // Operator-chosen display name (optional, already normalised). Public by + // construction: it exists to label this node in other nodes' discovery + // lists, so only ever include what clean_display_name lets through. + if let Some(n) = name.and_then(clean_display_name) { + fields["name"] = serde_json::Value::String(n); + } + let content = fields.to_string(); for url in relays { let _ = client.add_relay(url).await; @@ -259,6 +288,9 @@ pub struct DiscoverableNode { pub nostr_npub: String, pub did: String, pub version: String, + /// Operator-chosen display name from the presence event. Untrusted peer + /// input — normalised through `clean_display_name` on the way in. + pub name: Option, } pub async fn discover_nodes( @@ -333,11 +365,16 @@ pub async fn discover_nodes( .ok() .and_then(|pk| pk.to_bech32().ok()) .unwrap_or_default(); + let name = content + .get("name") + .and_then(|v| v.as_str()) + .and_then(clean_display_name); nodes.push(DiscoverableNode { nostr_pubkey, nostr_npub, did, version, + name, }); } } diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index c5d9b06f..9334eef2 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -234,10 +234,9 @@ impl Server { tokio::spawn(async move { const HEARTBEAT_SECS: u64 = 12 * 3600; // < PRESENCE_TTL_SECS/3 loop { - let enabled = - nostr_handshake::discovery_enabled_override(&data_dir_for_relays) - .await - .unwrap_or(config_flag); + let (enabled_override, display_name) = + nostr_handshake::discovery_overrides(&data_dir_for_relays).await; + let enabled = enabled_override.unwrap_or(config_flag); if enabled { let relays = crate::nostr_relays::merged_relay_list( &data_dir_for_relays, @@ -249,6 +248,7 @@ impl Server { &identity_dir, &did, &version, + display_name.as_deref(), &relays, tor_proxy.as_deref(), ) diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index dfcf2f51..55d2f9c5 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -884,14 +884,16 @@ class RPCClient { // `handshake.poll` queues inbound requests into the federation pending // inbox for manual approval (it does NOT auto-accept). - async nostrDiscoveryStatus(): Promise<{ enabled: boolean; npub?: string | null }> { + async nostrDiscoveryStatus(): Promise<{ enabled: boolean; npub?: string | null; name?: string | null }> { return this.call({ method: 'nostr.discovery-status', params: {} }) } - async nostrSetDiscovery(enabled: boolean): Promise<{ enabled: boolean }> { + async nostrSetDiscovery(enabled: boolean, name?: string): Promise<{ enabled: boolean }> { + // `name` omitted = backend keeps the stored display name; empty string + // clears it. Only sent when the caller explicitly provides it. return this.call({ method: 'nostr.set-discovery', - params: { enabled }, + params: name === undefined ? { enabled } : { enabled, name }, timeout: 30000, }) } @@ -902,6 +904,7 @@ class RPCClient { nostr_npub: string did: string version: string + name?: string | null }> }> { return this.call({ method: 'handshake.discover', params: {}, timeout: 30000 }) diff --git a/neode-ui/src/views/web5/Web5NodeVisibility.vue b/neode-ui/src/views/web5/Web5NodeVisibility.vue index c7fb2099..cd6aba50 100644 --- a/neode-ui/src/views/web5/Web5NodeVisibility.vue +++ b/neode-ui/src/views/web5/Web5NodeVisibility.vue @@ -55,7 +55,10 @@

{{ t('web5.yourNodeNpub') }}

-

{{ nodeNpub }}

+

{{ nodeName }}

+ +

{{ midNpub(nodeNpub) }}

+ +
+ + + + @@ -139,15 +169,21 @@ const emit = defineEmits<{ const nodeVisibility = ref('hidden') const nodeNpub = ref(null) +const nodeName = ref(null) const visibilityLoading = ref(false) const settingVisibility = ref(false) const discoverEnabled = ref(false) +// Name-prompt state: turning discovery ON routes through a small dialog so +// the operator can (optionally) name the node before it announces itself. +const showNameModal = ref(false) +const nameInput = ref('') interface DiscoverableNode { nostr_pubkey: string nostr_npub: string did: string version: string + name?: string | null } const discoveredNodes = ref([]) @@ -155,6 +191,12 @@ const discovering = ref(false) const requestingPeer = ref(null) const requestedPeers = ref(new Set()) +/** Own-npub display: keep the start and the FULL tail visible, ellipsis in + * the middle. (shortNpub below stays as-is — it formats the discovered list.) */ +function midNpub(npub: string): string { + return npub.length > 24 ? `${npub.slice(0, 12)}…${npub.slice(-10)}` : npub +} + function shortNpub(npub: string): string { if (!npub) return 'unknown' return npub.length > 21 ? `${npub.slice(0, 12)}…${npub.slice(-6)}` : npub @@ -175,6 +217,7 @@ async function loadVisibility() { ]) discoverEnabled.value = !!disc.enabled nodeNpub.value = disc.npub || null + nodeName.value = disc.name || null nodeVisibility.value = (vis?.visibility as VisibilityLevel) || 'hidden' if (discoverEnabled.value) void discoverNodes() } catch { @@ -186,10 +229,33 @@ async function loadVisibility() { async function toggleDiscoverable(enabled: boolean) { if (settingVisibility.value) return + if (enabled) { + // Turning ON goes through the name dialog: the node is about to announce + // itself publicly, and this is the natural moment to (optionally) name it. + nameInput.value = nodeName.value || '' + showNameModal.value = true + return + } + await applyDiscovery(false) +} + +function cancelNameModal() { + showNameModal.value = false + // The switch never actually flipped server-side; snap the UI back. + discoverEnabled.value = false +} + +async function confirmNameModal() { + showNameModal.value = false + // Send exactly what's in the box: text sets the name, blank clears it. + await applyDiscovery(true, nameInput.value.trim()) +} + +async function applyDiscovery(enabled: boolean, name?: string) { settingVisibility.value = true try { // Public means public: the switch drives nostr presence publishing. - const res = await rpcClient.nostrSetDiscovery(enabled) + const res = await rpcClient.nostrSetDiscovery(enabled, name) discoverEnabled.value = !!res.enabled // Keep the legacy visibility string in sync (cosmetic; best-effort). const level: VisibilityLevel = enabled ? 'public' : 'hidden' @@ -198,8 +264,17 @@ async function toggleDiscoverable(enabled: boolean) { .then(() => { nodeVisibility.value = level }) .catch(() => {}) emit('toast', enabled ? 'Node is now publicly discoverable' : 'Node hidden from discovery') - if (enabled) void discoverNodes() - else discoveredNodes.value = [] + if (enabled) { + if (name !== undefined) nodeName.value = name || null + // Re-read status so the npub/name shown reflect post-enable state + // without a page reload. + rpcClient.nostrDiscoveryStatus() + .then((s) => { nodeNpub.value = s.npub || null; nodeName.value = s.name || null }) + .catch(() => {}) + void discoverNodes() + } else { + discoveredNodes.value = [] + } } catch { emit('toast', t('web5.failedToUpdateVisibility')) } finally {