diff --git a/core/archipelago/src/api/rpc/mesh/typed_messages.rs b/core/archipelago/src/api/rpc/mesh/typed_messages.rs index f0709726..da0b0326 100644 --- a/core/archipelago/src/api/rpc/mesh/typed_messages.rs +++ b/core/archipelago/src/api/rpc/mesh/typed_messages.rs @@ -220,6 +220,12 @@ impl RpcHandler { .ok_or_else(|| anyhow::anyhow!("Missing cid"))? .to_string(); let caption = params["caption"].as_str().map(|s| s.to_string()); + // Optional override: frontend can resolve the peer's federation onion + // itself (by matching mesh peer name against federation.list-nodes) + // and pass it in directly. Bypasses the mesh-peer→DID→federation + // lookup, which currently fails because meshcore adverts don't carry + // the archipelago master DID. + let explicit_peer_onion = params["peer_onion"].as_str().map(|s| s.to_string()); // Pull the shared blob store (set by ApiHandler at startup). let blob_store = { @@ -237,16 +243,21 @@ impl RpcHandler { .as_ref() .ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?; - // Resolve peer Ed25519 pubkey from contact_id so we can scope the cap. + // Resolve peer Ed25519 pubkey + DID from contact_id. The pubkey scopes + // the capability token; the DID is the stable cross-transport identity + // used to match the peer against our federation node list (mesh and + // federation use different ed25519 keys but the same master DID). let state = svc.shared_state(); - let peer_pubkey_hex = { + let (peer_pubkey_hex, peer_did) = { let peers = state.peers.read().await; let peer = peers .get(&contact_id) .ok_or_else(|| anyhow::anyhow!("Unknown mesh contact_id {}", contact_id))?; - peer.pubkey_hex + let pk = peer + .pubkey_hex .clone() - .ok_or_else(|| anyhow::anyhow!("Peer has no pubkey yet"))? + .ok_or_else(|| anyhow::anyhow!("Peer has no pubkey yet"))?; + (pk, peer.did.clone()) }; // Our onion (stripped of scheme/trailing slash) for the URL the receiver will hit. @@ -291,14 +302,21 @@ impl RpcHandler { // budget (cid alone is 64 hex chars, plus onion + cap). Route via // federation when the peer has a known onion; fall back to LoRa // only for tiny envelopes that could theoretically fit. - let federation_onion = { + // Match mesh peer → federation node by master DID, NOT by pubkey. + // Mesh adverts carry a LoRa-local ed25519 key that differs from the + // archipelago node's identity key in federation/nodes.json; the DID + // is the only stable identifier the two transports share. + let federation_onion = if let Some(onion) = explicit_peer_onion { + Some(onion) + } else { let nodes = crate::federation::load_nodes(&self.config.data_dir) .await .unwrap_or_default(); - nodes - .into_iter() - .find(|n| n.pubkey == peer_pubkey_hex) - .map(|n| n.onion) + if let Some(did) = peer_did.as_ref() { + nodes.into_iter().find(|n| &n.did == did).map(|n| n.onion) + } else { + None + } }; let msg = if let Some(onion) = federation_onion { svc.send_typed_wire_via_federation( diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 5f4f0aaf..3c5a4bd4 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -601,17 +601,31 @@ impl MeshService { wire: Vec, ) -> Result<()> { let envelope = crate::mesh::message_types::TypedEnvelope::from_wire(&wire)?; - // Find the contact_id by pubkey match; fall back to synthetic. + // The sender's `from_pubkey_hex` is their archipelago identity key, + // which differs from the mesh peer's LoRa advert pubkey. Resolve + // identity → DID → mesh contact_id via federation/nodes.json (the + // DID is the only stable cross-transport key). + let federation_did = { + let nodes = crate::federation::load_nodes(&self.data_dir) + .await + .unwrap_or_default(); + nodes + .into_iter() + .find(|n| n.pubkey == from_pubkey_hex) + .map(|n| n.did) + }; let contact_id = { let peers = self.state.peers.read().await; peers .iter() .find_map(|(cid, p)| { - if p.pubkey_hex.as_deref() == Some(from_pubkey_hex) { - Some(*cid) - } else { - None - } + let did_match = federation_did + .as_ref() + .zip(p.did.as_ref()) + .map(|(a, b)| a == b) + .unwrap_or(false); + let pk_match = p.pubkey_hex.as_deref() == Some(from_pubkey_hex); + if did_match || pk_match { Some(*cid) } else { None } }) .unwrap_or_else(|| { let bytes = hex::decode(from_pubkey_hex).unwrap_or_default(); diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index 84506409..5a4f0dd5 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -382,13 +382,13 @@ export const useMeshStore = defineStore('mesh', () => { } } - async function sendContent(contactId: number, cid: string, caption?: string) { + async function sendContent(contactId: number, cid: string, caption?: string, peerOnion?: string) { try { sending.value = true error.value = null const res = await rpcClient.call<{ sent: boolean; message_id: number; cid: string; size: number }>({ method: 'mesh.send-content', - params: { contact_id: contactId, cid, caption }, + params: { contact_id: contactId, cid, caption, peer_onion: peerOnion }, }) if (res.sent) await fetchMessages() return res diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index 0b42c7ac..591b725e 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -639,7 +639,31 @@ async function handleAttachFile(ev: Event) { return } const { cid } = (await up.json()) as { cid: string } - await mesh.sendContent(activeChatPeer.value.contact_id, cid, messageText.value.trim() || undefined) + // Resolve the federation onion for this mesh peer. Meshcore adverts + // don't carry an archipelago DID so the backend can't link them on its + // own — we match on name (both sides use the node's display name). + // Falls back to undefined; the backend will try its own DID lookup or + // error out if no federation path exists. + let peerOnion: string | undefined + try { + const fed = await rpcClient.federationListNodes() + const peerName = activeChatPeer.value.advert_name + const hit = fed.nodes.find( + (n: { name?: string; onion?: string }) => + (n.name ?? '').toLowerCase() === peerName.toLowerCase() || + (n.name ?? '').toLowerCase().includes(peerName.toLowerCase()) || + peerName.toLowerCase().includes((n.name ?? '').toLowerCase()), + ) + peerOnion = hit?.onion ?? undefined + } catch { + /* non-fatal — backend will try its own lookup */ + } + await mesh.sendContent( + activeChatPeer.value.contact_id, + cid, + messageText.value.trim() || undefined, + peerOnion, + ) messageText.value = '' nextTick(() => scrollChatToBottom()) } catch (e) {