From 1ea3f8d65cd4ca038032b119d1f23b534dc8703c Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 17 Jun 2026 03:24:34 -0400 Subject: [PATCH] fix(mesh): message federation contacts without a radio (fixes 'Missing contact_id') Messaging a federation-only peer (e.g. 'Arch Dev') failed with 'Missing contact_id'. The UI gave federation-only rows a *negative* placeholder contact_id derived from a DID hash, but the backend parses contact_id as u64, so a negative value deserialized to None. The negative id also never matched the positive federation-synthetic id that federation-routed messages are stored under, so those threads looked empty. - Frontend: derive the SAME positive federation-synthetic id the backend uses (federationContactId mirrors federation_peer_contact_id) so mesh.send accepts it and messages thread correctly. - Backend: send_typed_wire now resolves a federation-synthetic contact_id from nodes.json when it isn't in the live mesh peer table (radio-less node), instead of bailing 'Unknown federation peer'. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/archipelago/src/mesh/mod.rs | 30 ++++++++++++++++++++++++------ neode-ui/src/views/Mesh.vue | 24 ++++++++++++++++-------- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 7adb301a..618348d1 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -825,15 +825,33 @@ impl MeshService { let is_federation_synthetic = contact_id & 0x8000_0000 != 0; let exceeds_lora = wire.len() > protocol::MAX_MESSAGE_LEN; if is_federation_synthetic || exceeds_lora { - let (peer_pubkey, peer_did) = { + // Resolve the peer's pubkey/did. Prefer the live mesh peer table, + // but fall back to federation storage for federation-synthetic ids + // that were never seeded into `state.peers` — e.g. a radio-less + // node where the mesh device table is empty. Without this fallback + // chatting a federation contact bails "Unknown federation peer" + // even though we know its onion from nodes.json. + let from_table = { let peers = self.state.peers.read().await; - match peers.get(&contact_id) { - Some(p) => (p.pubkey_hex.clone(), p.did.clone()), - None if is_federation_synthetic => { - anyhow::bail!("Unknown federation peer {}", contact_id); + peers + .get(&contact_id) + .map(|p| (p.pubkey_hex.clone(), p.did.clone())) + }; + let (peer_pubkey, peer_did) = match from_table { + Some(v) => v, + None if is_federation_synthetic => { + let nodes = crate::federation::load_nodes(&self.data_dir) + .await + .unwrap_or_default(); + match nodes + .iter() + .find(|n| federation_peer_contact_id(&n.pubkey) == contact_id) + { + Some(n) => (Some(n.pubkey.clone()), Some(n.did.clone())), + None => anyhow::bail!("Unknown federation peer {}", contact_id), } - None => (None, None), } + None => (None, None), }; let nodes = crate::federation::load_nodes(&self.data_dir) .await diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index 3a6e6dbb..ab2094d1 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -626,10 +626,12 @@ const mergedPeers = computed(() => { if (groups.has(key)) continue // Synthesise a placeholder MeshPeer so openChat() and the existing // rssi/avatar template paths don't need a separate code path for - // "federation-only" rows. The contact_id is a stable negative number - // derived from the DID hash so it never collides with real mesh - // contact_ids (which are u32 from the radio firmware). - const synthCid = -100 - Math.abs(hashStringToInt(fed.did)) + // "federation-only" rows. The contact_id MUST match the backend's + // federation-synthetic id (high bit set, derived from the pubkey) so + // `mesh.send` accepts it (it parses contact_id as u64 — a negative + // placeholder fails with "Missing contact_id") and so federation-routed + // messages stored under that id land in this thread. + const synthCid = federationContactId(fed.pubkey) const placeholder: MeshPeer = { contact_id: synthCid, advert_name: fed.name || fed.did, @@ -657,10 +659,16 @@ const mergedPeers = computed(() => { return Array.from(groups.values()) }) -function hashStringToInt(s: string): number { - let h = 0 - for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0 - return h +// Mirror of the backend's `federation_peer_contact_id` (mesh/mod.rs): take the +// first 4 bytes of the archipelago pubkey as a little-endian u32, clear the top +// bit, then set it as the federation marker. Producing the SAME id here means a +// federation-only row addresses the exact contact the backend routes/stores +// under, instead of an unsendable negative placeholder. +function federationContactId(pubkeyHex: string): number { + const bytes = pubkeyHex.match(/../g)?.map(h => parseInt(h, 16)) ?? [] + if (bytes.length < 4) return 0x80000001 + const low = ((bytes[0]) | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)) >>> 0 + return ((0x80000000 | (low & 0x7fffffff)) >>> 0) } // activeChatPeer is a single MeshPeer (the row the user clicked). To unify