fix(demo): mesh attachment send parity with real nodes
All checks were successful
Demo images / Build & push demo images (push) Successful in 5m44s
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:
parent
8c2a55eb4c
commit
c2ce71c680
@ -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(),
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user