From 6020c7f8dfbeceb5a9dac8a0adb3702bc06bc764 Mon Sep 17 00:00:00 2001 From: s1to Date: Fri, 7 Aug 2026 21:06:13 +0200 Subject: [PATCH] =?UTF-8?q?[0.9.1]=20FORK=5FIA=5FUX:=20Fase=202=20cross-de?= =?UTF-8?q?vice=20=E2=80=94=20relay=20Karvan=20messages=20over=20SSB=20pri?= =?UTF-8?q?vate=20msgs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes a Karvan room work between two different phones once they're connected to a pub: - karvan_model: rooms track remoteFeeds (SSB ids of other participants); addRemoteFeed/ getRemoteFeeds/anyRemoteRooms (feed-id validated, capped at MAX_MEMBERS). - backend: inviting registers the invitee's feed; joining by link registers the inviter's feed (read from the invite PM). Posting a message publishes a private 'karvan-relay' to those feeds; an efficient LIVE log-stream subscriber (createLogStream old:false live:true) ingests incoming relays and injects them into the local mirror room (dedup by mid, skips own → no loop). WebRTC SIGNAL frames are NOT relayed (too many/slow for the log). - Chosen over a muxrpc plugin because a classic pub only replicates the LOG (store-and-forward); live muxrpc wouldn't reach the other phone. No SSB-startup changes → no boot risk. Verified: boot OK with the live stream, invite/adopt register feeds, message posts + relay publishes, server stays up, 16/16 tests. Cross-device delivery needs the user's 2-phone+pub test. Note: text chat only — video still needs the wrapper camera permission (Fase 3). --- src/backend/backend.js | 62 +++++++++++++++++++++++++++++++++++++- src/models/karvan_model.js | 18 ++++++++++- test/karvan_model.test.js | 12 ++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/src/backend/backend.js b/src/backend/backend.js index 6e5c372..763ae8a 100644 --- a/src/backend/backend.js +++ b/src/backend/backend.js @@ -638,6 +638,59 @@ const trendingModel = require('../models/trending_model')({ cooler, isPublic: co const statsModel = require('../models/stats_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel }); const padsModel = require('../models/pads_model')({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel }); const karvanModel = require('../models/karvan_model')({}); // FORK_IA_UX: salas efímeras en RAM (mensajes temporales + señalización WebRTC) + +// FORK_IA_UX Fase 2 (cross-device): relay de MENSAJES de Karvan por privados SSB. Funciona vía un pub +// (el privado se replica al otro móvil); los SIGNAL de WebRTC NO se relayan (serían demasiados/lentos por el log). +// No toca el arranque del SSB. Ingesta por stream EN VIVO (no polling) → eficiente. +const karvanSeenRelay = new Set(); // mids ya inyectados (evita duplicados/bucles) +let karvanRelaySub = false; // ¿suscriptor del log en vivo montado? +async function karvanPublishRelay(roomId, mid, from, text) { + try { + const feeds = karvanModel.getRemoteFeeds(roomId); + if (!feeds.length) return; + const ssbClient = await cooler.open(); + const recps = [...new Set([ssbClient.id, ...feeds])].slice(0, 8); + await new Promise((res, rej) => ssbClient.private.publish( + { type: 'karvan-relay', roomId, mid: String(mid || '').slice(0, 60), from: String(from || '?').slice(0, 80), text: String(text || '').slice(0, 2000), private: true }, + recps, (e, m) => e ? rej(e) : res(m))); + } catch (e) {} +} +async function karvanStartRelay() { + if (karvanRelaySub) return; + karvanRelaySub = true; + try { + const ssbClient = await cooler.open(); + const me = ssbClient.id; + pull( + ssbClient.createLogStream({ old: false, live: true }), + pull.drain((m) => { + try { + if (!m || !m.value) return; + let dec; try { dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.timestamp }); } catch (_) { return; } + const c = dec && dec.value && dec.value.content; + if (!c || c.type !== 'karvan-relay' || !c.roomId || !c.mid) return; + if (dec.value.author === me) return; // no reinyectar lo propio (evita bucle A→B→A) + if (karvanSeenRelay.has(c.mid)) return; + if (!karvanModel.getRoom(c.roomId)) return; // solo si tenemos esa sala localmente + karvanSeenRelay.add(c.mid); + if (karvanSeenRelay.size > 5000) karvanSeenRelay.clear(); + karvanModel.postMessage(c.roomId, { from: c.from, text: c.text, mid: c.mid }); + } catch (e) {} + }) + ); + } catch (e) { karvanRelaySub = false; } +} +// ¿quién me invitó a esta sala? (para registrar su feed en el espejo local) — reutiliza el lector de privados +async function karvanInviteAuthor(roomId) { + try { + const msgs = await pmModel.listAllPrivate(); + for (const m of (msgs || [])) { + const c = m && m.value && m.value.content; + if (c && c.subject === 'KARVAN_INVITE' && typeof c.text === 'string' && c.text.indexOf('/karvan/' + roomId) !== -1) return m.value.author; + } + } catch (e) {} + return null; +} const tagsModel = require('../models/tags_model')({ cooler, isPublic: config.public, padsModel, tribesModel }); const tribesContentModel = require('../models/tribes_content_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel }); const searchModel = require('../models/search_model')({ cooler, isPublic: config.public, padsModel, tribeCrypto, tribesModel }); @@ -3658,11 +3711,16 @@ router // unirse por enlace/invitación crea un espejo local, pero SOLO en una navegación real (documento): // un /subrecurso de una web maliciosa NO debe poder crear/expulsar salas por GET (CSRF). El navegador // controla sec-fetch-dest/accept, así que son señales fiables. + let justAdopted = false; if (!room) { const isNav = ctx.get('sec-fetch-dest') === 'document' || (ctx.get('accept') || '').includes('text/html'); - if (isNav) room = karvanModel.adoptRoom({ id: ctx.params.id }); + if (isNav) { room = karvanModel.adoptRoom({ id: ctx.params.id }); justAdopted = !!room; } } if (!room) { ctx.redirect('/karvan'); return; } + if (justAdopted) { // al unirte por enlace, registra el feed de quien te invitó → relay cross-device + const author = await karvanInviteAuthor(ctx.params.id); + if (author) { karvanModel.addRemoteFeed(ctx.params.id, author); karvanStartRelay(); } + } let selfId = ''; try { selfId = getViewerId(); } catch (e) {} const msgs = karvanModel.listMessages(room.id, 0) || []; ctx.body = karvanRoomView(room, msgs, { selfId, invite: ctx.query.invite || '' }); @@ -3679,6 +3737,7 @@ router // le llega al INBOX del contacto con un enlace a la sala. Sin código SSB nuevo. await pmModel.sendMessage([to], 'KARVAN_INVITE', 'You have been invited to an ephemeral Karvan room' + (room.title ? ' ("' + room.title + '")' : '') + ' -> /karvan/' + id); + karvanModel.addRemoteFeed(id, to); karvanStartRelay(); // relay cross-device hacia el invitado ctx.redirect('/karvan/' + id + '?invite=ok'); } catch (e) { ctx.redirect('/karvan/' + id + '?invite=fail'); } }) @@ -3692,6 +3751,7 @@ router mid: b.mid, }); if (!msg) { ctx.status = 404; ctx.body = { error: 'no room' }; return; } + karvanPublishRelay(ctx.params.id, msg.mid || (msg.from + '-' + msg.ts + '-' + msg.seq), msg.from, msg.text); // → feeds remotos (si hay) const wantsJson = (ctx.get('accept') || '').indexOf('application/json') !== -1 || (ctx.get('content-type') || '').indexOf('application/json') !== -1; if (wantsJson) ctx.body = { ok: true, seq: msg.seq }; diff --git a/src/models/karvan_model.js b/src/models/karvan_model.js index 23f26e7..529e650 100644 --- a/src/models/karvan_model.js +++ b/src/models/karvan_model.js @@ -40,6 +40,7 @@ module.exports = ({ idleTtlMs = DEFAULT_IDLE_TTL_MS, absTtlMs = DEFAULT_ABS_TTL_ room.messages.length = 0; room.signals.length = 0; room.members.clear(); + room.remoteFeeds.clear(); rooms.delete(id); }; @@ -62,6 +63,7 @@ module.exports = ({ idleTtlMs = DEFAULT_IDLE_TTL_MS, absTtlMs = DEFAULT_ABS_TTL_ messages: [], signals: [], members: new Set(), + remoteFeeds: new Set(), // feeds SSB de otros nodos con esta sala (para relay cross-device por privados) seq: 0, sigSeq: 0, idleTimer: null, @@ -74,6 +76,17 @@ module.exports = ({ idleTtlMs = DEFAULT_IDLE_TTL_MS, absTtlMs = DEFAULT_ABS_TTL_ return publicRoom(room); }; + // Fase 2 (cross-device): registra el feed SSB de otro participante (de una invitación) para relayarle. + const addRemoteFeed = (id, feed) => { + const room = rooms.get(id); + if (!room || typeof feed !== "string" || !/^@[A-Za-z0-9+/=]{20,}\.ed25519$/.test(feed)) return false; + if (room.remoteFeeds.has(feed) || room.remoteFeeds.size < MAX_MEMBERS) { room.remoteFeeds.add(feed); return true; } + return false; + }; + const getRemoteFeeds = (id) => { const r = rooms.get(id); return r ? [...r.remoteFeeds] : []; }; + // ¿hay ALGUNA sala con feeds remotos? (para no montar el relay si no hace falta) + const anyRemoteRooms = () => { for (const r of rooms.values()) if (r.remoteFeeds.size) return true; return false; }; + // Fase 2 (invitar): unirse a una sala por su id creando un ESPEJO local (desde un enlace/invitación). // El id es la "capacidad": quien lo tiene puede unirse. Aún NO conecta con la sala del otro nodo // (eso es la señalización cross-device, pendiente); crea la sala local para poder abrir el enlace. @@ -91,7 +104,7 @@ module.exports = ({ idleTtlMs = DEFAULT_IDLE_TTL_MS, absTtlMs = DEFAULT_ABS_TTL_ if (Number.isFinite(t) && t > 0) absMs = Math.min(t * 60000, MAX_ABS_TTL_MS); const room = { id, title: String(title || "").slice(0, 80), createdAt, expiresAt: createdAt + absMs, - messages: [], signals: [], members: new Set(), seq: 0, sigSeq: 0, idleTimer: null, absTimer: null, + messages: [], signals: [], members: new Set(), remoteFeeds: new Set(), seq: 0, sigSeq: 0, idleTimer: null, absTimer: null, }; rooms.set(id, room); room.absTimer = setTimeout(() => destroy(id), absMs); @@ -171,6 +184,9 @@ module.exports = ({ idleTtlMs = DEFAULT_IDLE_TTL_MS, absTtlMs = DEFAULT_ABS_TTL_ return { createRoom, adoptRoom, + addRemoteFeed, + getRemoteFeeds, + anyRemoteRooms, listRooms, getRoom, postMessage, diff --git a/test/karvan_model.test.js b/test/karvan_model.test.js index 385d08c..9b26b5e 100644 --- a/test/karvan_model.test.js +++ b/test/karvan_model.test.js @@ -166,6 +166,18 @@ async function test(name, fn) { assert.ok(m.postSignal(r.id, { from: "a", to: "b", payload: { sdp: "small" } }), "payload pequeño pasa"); }); + await test("feeds remotos: addRemoteFeed / getRemoteFeeds / anyRemoteRooms + validación", () => { + const m = makeModel(); + const r = m.createRoom({}); + assert.strictEqual(m.anyRemoteRooms(), false); + const feed = "@" + "A".repeat(43) + "=.ed25519"; + assert.strictEqual(m.addRemoteFeed(r.id, feed), true); + assert.deepStrictEqual(m.getRemoteFeeds(r.id), [feed]); + assert.strictEqual(m.anyRemoteRooms(), true); + assert.strictEqual(m.addRemoteFeed(r.id, "no-es-feed"), false); // formato inválido + assert.strictEqual(m.addRemoteFeed("noexiste", feed), false); // sala inexistente + }); + console.log("\n" + passed + " passed, " + failed + " failed"); process.exit(failed ? 1 : 0); })();