fix(demo): mesh attachment send parity with real nodes
All checks were successful
Demo images / Build & push demo images (push) Successful in 5m44s

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 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-29 11:03:21 -04:00
parent 8c2a55eb4c
commit c2ce71c680

View File

@ -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(),
}