From c2ce71c680a0272a1f6999e06a84fde8533c8b6f Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 29 Jul 2026 11:03:21 -0400 Subject: [PATCH] fix(demo): mesh attachment send parity with real nodes Demo attach flow failed with 'Method not found: mesh.send-content-inline' and force-opened the transport chooser modal real nodes don't show. - mesh.transport-advice now mirrors the daemon's size-based tier logic (typed_messages.rs): chooser only in the fits-both 1-2.3KB band - implement mesh.send-content-inline / send-content / fetch-content and POST /api/blob; bytes live in the per-visitor session store - sent texts + attachments persist in mesh.messages so refresh-after-send shows them, same as a real node Co-Authored-By: Claude Fable 5 --- neode-ui/mock-backend.js | 144 +++++++++++++++++++++++++++++++++++---- 1 file changed, 130 insertions(+), 14 deletions(-) diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js index 936066c7..c16e093e 100755 --- a/neode-ui/mock-backend.js +++ b/neode-ui/mock-backend.js @@ -1488,6 +1488,20 @@ app.get('/api/app-catalog', async (_req, res) => { res.type('application/json').send(json) } catch { res.status(404).json({ error: 'not found' }) } }) +// Mesh attachment upload (Tor/content-ref path) — mirrors the real node's +// blob endpoint: raw bytes in, {cid} out. Bytes stay in the visitor's +// per-session store; mesh.fetch-content serves them back as a data: URL. +app.post('/api/blob', express.raw({ type: () => true, limit: '25mb' }), (req, res) => { + const buf = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0) + const cid = 'demo-blob-' + crypto.createHash('sha256').update(buf).digest('hex').slice(0, 16) + currentStore().mesh.blobs[cid] = { + mime: req.get('X-Blob-Mime') || 'application/octet-stream', + filename: req.get('X-Blob-Filename') || null, + thumb_b64: req.get('X-Blob-Thumb') || null, + b64: buf.toString('base64'), + } + res.json({ cid }) +}) app.get('/api/container/logs', (_req, res) => res.json({ logs: [ '[INF] LND: Version 0.18.3-beta commit=v0.18.3-beta', @@ -3082,10 +3096,13 @@ app.post('/rpc/v1', (req, res) => { { id: 11, direction: 'received', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Dead man switch check-in. All systems nominal. Battery 78%.', timestamp: new Date(now - 60000).toISOString(), delivered: true, encrypted: true, transport: 'reticulum', sender_pubkey: 'demo11abababababababababababababababababababababababababababab', sender_seq: 11, message_type: 'alert', typed_payload: { alert_type: 'status', message: 'All systems nominal. Battery 78%. Mesh uptime 14d.', signed: true } }, { id: 12, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Invoice paid: 50,000 sats', timestamp: new Date(now - 30000).toISOString(), delivered: true, encrypted: true, transport: 'fips', sender_pubkey: 'demo12abababababababababababababababababababababababababababab', sender_seq: 12, message_type: 'invoice', typed_payload: { bolt11: 'lnbc500000n1pjmesh...truncated...', amount_sats: 50000, memo: 'Channel opening fee', paid: true, payment_hash: 'a1b2c3d4e5f6...' } }, ] + // Messages sent this session (texts + attachments) ride after the + // static seed so refresh-after-send shows them, same as a real node. + const withDynamic = [...allMessages, ...currentStore().mesh.dynamic] return res.json({ result: { - messages: allMessages.slice(0, limit), - count: allMessages.length, + messages: withDynamic.slice(0, limit), + count: withDynamic.length, }, }) } @@ -3100,10 +3117,19 @@ app.post('/rpc/v1', (req, res) => { { id: 4, name: 'bunker-alpha', encrypted: true }, ].find(p => p.id === contactId) console.log(`[Mesh] Send to ${peer?.name || contactId}: ${message}`) + const dyn = currentStore().mesh.dynamic + const id = 100 + dyn.length + dyn.push({ + id, direction: 'sent', peer_contact_id: contactId, peer_name: peer?.name || `peer-${contactId}`, + plaintext: message, timestamp: new Date().toISOString(), delivered: true, + encrypted: peer?.encrypted ?? false, transport: 'meshcore', + sender_pubkey: 'demo02abababababababababababababababababababababababababababab', + sender_seq: id, message_type: 'text', + }) return res.json({ result: { sent: true, - message_id: Math.floor(Math.random() * 10000) + 100, + message_id: id, encrypted: peer?.encrypted ?? false, }, }) @@ -4284,28 +4310,114 @@ app.post('/rpc/v1', (req, res) => { // ── Mesh chat polish + flash flow (v1.7.117/118 demo coverage) ────── - // Image-modal "Send via" pills: most demo peers are federation-reachable - // (LoRa + FIPS + Tor pills all offered); mountain-node (3) stays - // radio-only to show the contrast. + // Mirrors the real daemon's size-based tier logic + // (typed_messages.rs handle_mesh_transport_advice) so the demo shows the + // SAME modals a real node would — the chooser only appears in the narrow + // fits-both band, never unconditionally. Demo radio is Meshcore; every + // peer except mountain-node (3) is federation-reachable (Tor + FIPS). case 'mesh.transport-advice': { + const MESH_AUTO_MAX = 1024 + const MESH_HARD_MAX = 2300 + const TOR_LARGE_WARN = 5 * 1024 * 1024 + const LORA_BYTES_PER_SEC = 50 + const size = params?.size || 0 const federated = params?.contact_id !== 3 + let tier, reason + if (size <= MESH_AUTO_MAX) { + tier = 'auto-mesh'; reason = 'Small enough to send inline over mesh' + } else if (size <= MESH_HARD_MAX) { + if (federated) { tier = 'choose'; reason = 'Fits over mesh (slow) or Tor (instant)' } + else { tier = 'auto-mesh'; reason = 'No Tor path — sending inline over mesh' } + } else if (size <= TOR_LARGE_WARN) { + if (federated) { tier = 'tor-only'; reason = 'Too large for mesh — Tor only' } + else { tier = 'impossible'; reason = 'Too large for mesh, and peer has no Tor path' } + } else { + if (federated) { tier = 'tor-only'; reason = 'Large file — receiver fetch may be slow' } + else { tier = 'impossible'; reason = 'Too large, and peer has no Tor path' } + } return res.json({ result: { - tier: federated ? 'choose' : 'auto-mesh', - est_seconds: federated ? 4 : 90, + tier, + est_seconds: Math.max(1, Math.ceil(size / LORA_BYTES_PER_SEC)), has_tor: federated, has_fips: federated, last_transport: federated ? 'fips' : null, - reason: federated - ? 'Peer reachable over the FIPS overlay — radio optional' - : 'Radio-only peer', - size: params?.size || 0, - mesh_auto_max: 24576, - mesh_hard_max: 262144, + reason, + size, + mesh_auto_max: MESH_AUTO_MAX, + mesh_hard_max: MESH_HARD_MAX, }, }) } + // Attachment sends — same RPC surface a real node exposes (dispatch.rs), + // so the demo attach flow exercises the identical frontend code path. + case 'mesh.send-content-inline': { + const contactId = params?.contact_id + const peerNames = { 1: 'archy-198', 2: 'satoshi-relay', 3: 'mountain-node', 4: 'bunker-alpha' } + const bytes = Buffer.from(params?.bytes_b64 || '', 'base64') + const cid = 'demo-inline-' + crypto.createHash('sha256').update(bytes).digest('hex').slice(0, 16) + const meshStore = currentStore().mesh + meshStore.blobs[cid] = { + mime: params?.mime || 'application/octet-stream', + filename: params?.filename || null, + b64: params?.bytes_b64 || '', + } + const id = 100 + meshStore.dynamic.length + meshStore.dynamic.push({ + id, direction: 'sent', peer_contact_id: contactId, peer_name: peerNames[contactId] || `peer-${contactId}`, + plaintext: `📎 ${params?.filename || params?.mime || 'attachment'}`, + timestamp: new Date().toISOString(), delivered: true, encrypted: contactId !== 3, + transport: 'meshcore', + sender_pubkey: 'demo02abababababababababababababababababababababababababababab', + sender_seq: id, message_type: 'content_ref', + typed_payload: { + cid, inline: true, + mime: params?.mime || 'application/octet-stream', + filename: params?.filename || null, + caption: params?.caption || null, + size: bytes.length, + sender_onion: '', cap_token: 'demo', cap_exp: Math.floor(Date.now() / 1000) + 86400, + }, + }) + return res.json({ result: { sent: true, message_id: id, cid, size: bytes.length } }) + } + + case 'mesh.send-content': { + const contactId = params?.contact_id + const peerNames = { 1: 'archy-198', 2: 'satoshi-relay', 3: 'mountain-node', 4: 'bunker-alpha' } + const meshStore = currentStore().mesh + const blob = meshStore.blobs[params?.cid] || {} + const size = blob.b64 ? Buffer.from(blob.b64, 'base64').length : 0 + const id = 100 + meshStore.dynamic.length + meshStore.dynamic.push({ + id, direction: 'sent', peer_contact_id: contactId, peer_name: peerNames[contactId] || `peer-${contactId}`, + plaintext: `📎 ${blob.filename || blob.mime || 'attachment'}`, + timestamp: new Date().toISOString(), delivered: true, encrypted: contactId !== 3, + transport: 'tor', + sender_pubkey: 'demo02abababababababababababababababababababababababababababab', + sender_seq: id, message_type: 'content_ref', + typed_payload: { + cid: params?.cid, inline: false, + mime: blob.mime || 'application/octet-stream', + filename: blob.filename || null, + caption: params?.caption || null, + size, + thumb_bytes: blob.thumb_b64 || undefined, + sender_onion: '', cap_token: 'demo', cap_exp: Math.floor(Date.now() / 1000) + 86400, + }, + }) + return res.json({ result: { sent: true, message_id: id, cid: params?.cid, size } }) + } + + case 'mesh.fetch-content': { + const blob = currentStore().mesh.blobs[params?.cid] + if (!blob) return res.json({ error: { code: -32000, message: 'Content not found' } }) + return res.json({ + result: { local_url: `data:${blob.mime || 'application/octet-stream'};base64,${blob.b64}` }, + }) + } + case 'mesh.flash-list-firmware': { const versions = { meshcore: ['v1.7.4', 'v1.7.2', 'v1.6.9'], @@ -5386,6 +5498,10 @@ function makeSessionStore() { mockState: seedMockState(), bitcoinRelayMockState: structuredClone(SEED_BTCRELAY), files: { tree: structuredClone(SEED_FILES), contents: structuredClone(SEED_FILE_CONTENTS), bytes: 0 }, + // Mesh chat mutable state: messages sent this session + attachment bytes + // (cid → {mime, filename, b64, thumb_b64}). Per-session so one demo + // visitor's uploads are never visible to another. + mesh: { dynamic: [], blobs: {} }, sockets: new Set(), lastSeen: Date.now(), }