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) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-17 03:24:34 -04:00
co-authored by Claude Opus 4.8
parent e456c9701b
commit 1ea3f8d65c
2 changed files with 40 additions and 14 deletions
+16 -8
View File
@@ -626,10 +626,12 @@ const mergedPeers = computed<MergedPeer[]>(() => {
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<MergedPeer[]>(() => {
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