/** * MeshBay Browser Transport — WebRTC DataChannel client. * * Connects to a MeshBay node via WebRTC DataChannel (P2P, E2E). * The hub is only used for signaling (SDP/ICE relay) — after connection, * all data flows directly between browser and node. * * Wire format: length-prefixed msgpack (4-byte big-endian + msgpack payload). * Same format as QUIC and TCP+TLS transports on the node side. * * Usage: * const transport = new MeshBayTransport(hubUrl, accessToken); * await transport.connect(nodeId, jwtToken, groupId); * const index = await transport.fetchIndex(); * const chunk = await transport.fetchChunk(fileId, 0); * transport.close(); * * This file is the core — connection, reconnection, leases, dispatch. The rest * of MeshBayTransport's methods, and the free functions they use, are in the * transport-*.js scripts the shell loads after it (see "Parts" at the end). */ async function _pkFromSk(skPkcs8B64) { const raw = Uint8Array.from(atob(skPkcs8B64), c => c.charCodeAt(0)); const sk = await crypto.subtle.importKey('pkcs8', raw, { name: 'X25519' }, true, ['deriveBits']); const jwk = await crypto.subtle.exportKey('jwk', sk); const b64url = jwk.x; const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/'); const pad = b64.length % 4; return pad ? b64 + '='.repeat(4 - pad) : b64; } async function _pkEdFromSk(skPkcs8B64) { const raw = Uint8Array.from(atob(skPkcs8B64), c => c.charCodeAt(0)); const sk = await crypto.subtle.importKey('pkcs8', raw, { name: 'Ed25519' }, true, ['sign']); const jwk = await crypto.subtle.exportKey('jwk', sk); const b64 = jwk.x.replace(/-/g, '+').replace(/_/g, '/'); const pad = b64.length % 4; return pad ? b64 + '='.repeat(4 - pad) : b64; } // Segments of 256 KB: 24 in flight is 6 MB, enough to keep playback fed over a // slow link and small enough that nothing accumulates. // How long to collect ICE candidates before sending the offer anyway. Long // enough for a STUN round trip on a slow link, short enough that a STUN server // that never answers costs a pause rather than the whole attempt. const ICE_GATHER_TIMEOUT_MS = 4000; // Answers to an offer that are about the hub's load, never about the node: 429 // is one of the hub's per-account ceilings (docs/MESHBAY_DESIGN.md §7.2), and // 502/503 is the hub restarting behind its proxy. An offer refused for either is // sent again, so it is not reported as a node that cannot be reached — which is // what a single 429 used to become in Search, for a group whose node was // answering every other offer in under a second. Anything else — 404 for a node // that is not connected, 403, 504 for a node that did not answer — fails at once: // retrying it would make a dead node cost time instead of costing nothing. const OFFER_RETRY_STATUSES = new Set([429, 502, 503]); // Worst case about twenty seconds of waiting, all of it on a hub that is // answering. Search's own deadline treats each retry as progress and is still // bounded by its ceiling. const OFFER_RETRY_DELAYS_MS = [500, 1000, 2000, 4000, 8000]; const OFFER_RETRY_AFTER_MAX_MS = 10000; /** * POST an offer, sending it again while the hub refuses it for load. * * `Retry-After` is honoured when the hub gives one, and every delay is jittered: * the refusals this exists for come in bursts — several groups dialled at once, * or every connection of a phone reconnecting as it wakes — and retries that * all land on the same millisecond would be refused together again. * * `isClosed` is asked after each wait, so a caller that gave up (Search's * deadline, a page that unmounted) sends no further offer on its behalf. */ async function postOffer(call, url, init, { isClosed, onRetry } = {}) { for (let attempt = 0; ; attempt += 1) { const resp = await call(url, init); if (resp.ok) return resp; if (!OFFER_RETRY_STATUSES.has(resp.status) || attempt >= OFFER_RETRY_DELAYS_MS.length) { const detail = await resp.json().catch(() => ({})); const err = new Error(`Signaling failed: ${resp.status} ${detail.detail || ''}`); err.status = resp.status; throw err; } const after = Number(resp.headers && resp.headers.get && resp.headers.get('Retry-After')); const base = after > 0 ? Math.min(after * 1000, OFFER_RETRY_AFTER_MAX_MS) : OFFER_RETRY_DELAYS_MS[attempt]; const delay = Math.round(base * (0.75 + Math.random() * 0.5)); if (onRetry) onRetry(resp.status, delay, attempt + 1); await new Promise((resolve) => setTimeout(resolve, delay)); if (isClosed && isClosed()) { const err = new Error('Transport closed while the hub was busy'); err.status = resp.status; throw err; } } } const STREAM_CREDITS = 24; function _aborted() { const err = new Error('Cancelled'); err.name = 'AbortError'; return err; } // Every request type that goes through the two-step admin_challenge / // admin_response flow (_authorizeAdminOp, transport-admin.js) — one entry per // `_authorizeAdminOp(msg, expectedOp, ...)` call site. Found live: enabling // the Music app and then saving its root folder in the same Settings visit // (the new merged Directories section makes this a natural, fast // back-to-back sequence) fired two of these within milliseconds of each // other. Both admin_challenge replies, and both domain acks afterward, // were routed by nothing more than "whichever request happens to be // oldest pending" — apps_enabled's challenge stole app_directories' slot, // then that request just sat there until its 30s timeout, having // never received a challenge to answer at all. Keying both hops by op name // (below, in _key and in _dispatch) fixes this without needing the node to // change anything — `op` is already on every admin_challenge, and this // list is what lets a response two steps later be tied back to the right // one. // Acks the node *broadcasts* to everyone in the group, which the requester // therefore also has to be handed. // // `_dispatch` resolves an admin ack against the pending request and returns, // which is right for an op whose caller already knows the value it chose. It is // wrong for these: every *other* connected client learns the change from the // broadcast, and the one that asked for it is the only one that does not, // because its own request swallowed its copy. Found twice — first on the root // table, then on Chat's directory, where it meant the pane went on showing an // unsaved-looking draft after a save that had worked. const BROADCAST_ACK_TYPES = new Set([ 'root_update_ack', 'root_eject_ack', 'root_plug_ack', 'root_add_ack', 'root_remove_ack', 'app_directories_ack', 'chat_directory_ack', 'chat_link_preview_ack', 'chat_epoch_ack', 'search_listed_ack', ]); /** Hand a broadcast ack to the callback that would have had it from a peer. */ function _replayBroadcast(transport, msg) { if (msg.type === 'app_directories_ack' && transport._onAppDirectories) { transport._onAppDirectories(msg.app, msg.directories || []); } else if (msg.type === 'chat_directory_ack' && transport._onChatDirectory) { transport._onChatDirectory(msg.path || ''); } else if (msg.type === 'chat_link_preview_ack' && transport._onChatLinkPreview) { transport._onChatLinkPreview(Boolean(msg.enabled)); } else if (msg.type === 'search_listed_ack' && transport._onSearchListed) { transport._onSearchListed(msg.listed !== false); } else if (msg.type === 'chat_epoch_ack') { transport._applyChatEpoch(msg); } else if (transport._onRootsChanged) { transport._onRootsChanged(msg); } } const ADMIN_OP_TYPES = new Set([ 'tmdb_override', 'tmdb_rematch', 'tmdb_config', 'tmdb_enabled', 'musicbrainz_enabled', 'file_delete', 'dir_delete', 'apps_enabled', 'set_scan_settings', 'member_revoke', 'root_add', 'root_remove', 'root_update', 'root_eject', 'root_plug', 'app_directories', 'chat_directory', 'chat_link_preview', 'chat_epoch', 'search_listed', 'member_unpin', 'gek_rotate', 'group_attach', 'group_detach', 'invite_create', 'invite_link_create', 'invite_cancel', ]); // ── Diagnostic trace (opt-in, off by default) ─────────────────────────────── // Ring buffer of transport health events (connection/ICE/DataChannel state // transitions, request timeouts, visibility changes, periodic health pings), // persisted to localStorage so a connection that gets stuck can be inspected // after the fact — the field case this exists for is a phone with no // devtools attached. Added while chasing a report of the transport going // unresponsive after a mobile screen lock of several minutes; kept in the // tree afterward rather than ripped out, since the next hard-to-reproduce // connection bug will want the same thing and it costs nothing while off. // // Enable once by opening the app with ?trace=1 in the URL — this persists in // localStorage, so every later visit stays in trace mode until ?trace=0 // clears it. Read the log back at any time by navigating to #mb-debug (e.g. // https://meshbay.org/app/#mb-debug), which replaces the page with a plain // text dump — no devtools required. const TRACE_KEY = 'mb_trace'; const TRACE_LOG_KEY = 'mb_trace_log'; const TRACE_MAX = 500; // How often to probe the channel with a ping while trace mode is on — purely // diagnostic (to see when a health check starts failing), not a keepalive: // must stay opt-in, never run by default. const TRACE_PING_INTERVAL_MS = 25000; (function _initTraceFlag() { try { const params = new URLSearchParams(location.search); if (params.has('trace')) { if (params.get('trace') === '0') localStorage.removeItem(TRACE_KEY); else localStorage.setItem(TRACE_KEY, '1'); } } catch { /* localStorage unavailable (private mode, etc.) — trace stays off */ } })(); function traceEnabled() { try { return localStorage.getItem(TRACE_KEY) === '1'; } catch { return false; } } function trace(event, data) { if (!traceEnabled()) return; try { const buf = JSON.parse(localStorage.getItem(TRACE_LOG_KEY) || '[]'); buf.push({ t: new Date().toISOString(), event, ...data }); while (buf.length > TRACE_MAX) buf.shift(); localStorage.setItem(TRACE_LOG_KEY, JSON.stringify(buf)); } catch { /* storage full or unavailable — tracing is best-effort */ } } window.MeshBayTrace = { enabled: traceEnabled, // So a module that is not this one can write to the same buffer. The // composer's own state is the half of the "chat hangs, the textbox is dead" // report transport.js cannot see, and it belongs in the same timeline as the // channel events it has to be read against. record: trace, dump() { try { return JSON.parse(localStorage.getItem(TRACE_LOG_KEY) || '[]'); } catch { return []; } }, clear() { try { localStorage.removeItem(TRACE_LOG_KEY); } catch { /* ignore */ } }, }; // STUN, two providers deep. On the desktop client the hostnames are resolved in // the main process (Node's resolver) and handed back as IPs: Chromium's P2P // socket manager fails every STUN hostname with ERR_NAME_NOT_RESOLVED in some // restricted-resolver environments (a libvirt/KVM guest was where this surfaced) // even though every other resolver on the box works. In a browser there is no // `meshbay` bridge and the hostnames are used directly — a browser resolves them // fine. Resolved once per run; a provider changing IPs is picked up on restart. const STUN_URLS = [ 'stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302', 'stun:stun.cloudflare.com:3478', // Mozilla retired stun.services.mozilla.com; the name no longer resolves. // Google + Cloudflare still cover a single-provider outage. ]; let _iceServersPromise = null; function iceServers() { if (!_iceServersPromise) { _iceServersPromise = (async () => { let urls = STUN_URLS; if (window.meshbay && typeof window.meshbay.resolveStun === 'function') { try { const r = await window.meshbay.resolveStun(STUN_URLS); if (Array.isArray(r) && r.length) urls = r; } catch { /* keep the hostname form */ } } return urls.map((u) => ({ urls: u })); })(); } return _iceServersPromise; } function _showTraceView() { { const renderTraceView = () => { const log = window.MeshBayTrace.dump(); const text = JSON.stringify(log, null, 2); document.body.innerHTML = ''; document.title = 'MeshBay — Diagnostic'; const bar = document.createElement('div'); bar.style.cssText = 'font-family:monospace;padding:8px;'; const copyBtn = document.createElement('button'); copyBtn.textContent = 'Copier'; copyBtn.onclick = () => { navigator.clipboard.writeText(text).catch(() => {}); }; const clearBtn = document.createElement('button'); clearBtn.textContent = 'Vider'; clearBtn.onclick = () => { window.MeshBayTrace.clear(); renderTraceView(); }; const refreshBtn = document.createElement('button'); refreshBtn.textContent = 'Rafraîchir'; refreshBtn.onclick = renderTraceView; const info = document.createElement('span'); info.textContent = ` — ${log.length} évènement(s) — trace ${traceEnabled() ? 'active' : 'inactive'}`; info.style.marginLeft = '8px'; bar.append(copyBtn, clearBtn, refreshBtn, info); const pre = document.createElement('pre'); pre.style.cssText = 'font-family:monospace;font-size:11px;white-space:pre-wrap;' + 'word-break:break-all;padding:8px;'; pre.textContent = text; document.body.append(bar, pre); }; renderTraceView(); } } // Fragment-only URL changes (typing #mb-debug into an already-loaded page, // or a link to it) do not reload the document, so DOMContentLoaded alone // would miss them — hashchange is what a same-document navigation fires. if (location.hash === '#mb-debug') { document.addEventListener('DOMContentLoaded', _showTraceView); } window.addEventListener('hashchange', () => { if (location.hash === '#mb-debug') _showTraceView(); }); // This build's half of the version range (meshbay_common/handshake.py's // MNP_VERSION and MNP_MIN_SUPPORTED). Declared on the handshake — the only // message where it is read — so a node we cannot speak to refuses us with a // code, instead of the mismatch surfacing as a field that is not there. // // The `v: '0.1'` on every other message in this file is the historical value // and is read by nothing; it is left alone deliberately. The range is // negotiated once, at the start, not restated per message. const MNP_V = '3.1'; // **Not** raised with it, and that is the whole difference between 3.0 and 3.1. // 3.0 was a flag day because a node older than it cannot grant the lease this // client opens for every download and upload, so talking to one would mean // every transfer failing for a reason the person cannot act on. 3.1 only adds // `user_blob_*`: a 3.0 node answers "unknown message type" and the client // stores its playlists on the next node it reaches, keeping its own copy // meanwhile (docs/playlists.md §6.4). Refusing every 3.0 node over a feature // that degrades this quietly would be the flag day nobody needed. const MNP_V_MIN = '3.0'; // Codes a NODE sends us, in its own vocabulary (meshbay_common/handshake.py's // check_version): `version_too_old` means *we* are too old for it, // `version_too_new` that it is too old for what we require. The client's own // check of the node names its two conditions separately — see // _checkNodeVersion, where reusing this table's wording would read backwards. const HANDSHAKE_REFUSALS = { version_too_old: 'This page is older than the node it is talking to. ' + 'Reload to pick up the current version.', version_too_new: 'This node is running an older MeshBay than this page needs. ' + 'Its operator has to update it.', version_unreadable: 'The node could not read this page\'s protocol version.', // Surfaced only when it was the *last* node the hub offered for the group — // group-page.js moves on to the next one on this code rather than stopping. not_hosted: 'No node the hub offered for this group is hosting it. Its ' + 'operator has to attach it on the node that holds its files.', }; const JOIN_REFUSALS = { code_required: 'This node does not know this browser yet. Ask the node operator ' + 'for a pairing code (meshbay-node operator pair).', code_invalid: 'That pairing code is not valid — it may be mistyped, expired, ' + 'already used, or issued for a different account.', key_changed: 'This account is already paired with a different key on this node. ' + 'If you reset your keys, the operator must unpin you before pairing again.', not_authorized_for_group: 'The node does not list you as a member of this group. ' + 'Being a member on the hub is not enough — ask the operator for an invite.', no_gek: 'This group has no key yet. The node operator must run ' + '`meshbay-node gek-init` for it.', signature_invalid: 'The node rejected the signature over your keys.', stale_request: 'Your clock is too far from the node\'s — check the system time.', group_mismatch: 'The node refused a request naming a different group.', }; /** * One transfer's slot on the node, from this side. * * The contract the transfer store depends on: `acquire()` resolves when the * node has granted the slot (immediately on a node that hands out none), and * `release(reason)` gives it back exactly once. Nothing else in the client * speaks to the node about slots. * * Two things here exist only because a queue can lie, and both are the * difference between "waiting" and "waiting for ever": * * - **the watchdog.** A grant is pushed, not polled, so a lost push leaves * this side waiting on a node that believes it has started. Re-asking is * free — the node is idempotent on `tr` — and it is the only thing that * recovers a message that did not arrive. * - **release is idempotent and unconditional.** A slot given back twice * costs nothing; one never given back is a member who cannot transfer * again until a timeout the node runs on its own. */ const LEASE_WATCHDOG_MS = 60000; class Lease { constructor(transport, tr, kind, bytes, chunks, onState) { this.transport = transport; this.tr = tr; this.kind = kind; this.bytes = bytes; this.chunks = chunks; this.state = 'opening'; this.ahead = 0; this.closed = false; this._onState = onState; this._granted = null; this._watchdog = 0; this._wait = new Promise((resolve) => { this._granted = resolve; }); } _request() { // A closed channel is not a failure here, and must not throw: the transport // reconnects on its own, `_reopenTransfers` re-asks for every live lease // when it does, and the watchdog below asks again meanwhile. // // This is the same tolerance `_fetchChunkResilient` already gives a chunk // request — and before leases existed, a chunk request was the first thing // to touch the channel, so a download started on a briefly dead connection // simply retried. Asking for a slot first made `_send` the first contact // and threw "DataChannel not open (state: closed)" out of `downloadEntry`, // where nothing catches it: a download that used to recover became an // error with no row in the widget to show it. Found by downloading a file // right after a connection dropped. try { this.transport._send({ type: 'transfer_open', v: '0.1', tr: this.tr, kind: this.kind, bytes: this.bytes, chunks: this.chunks, }); } catch (err) { console.warn('[MeshBay] could not ask for a slot yet:', err.message); } this._arm(); } _arm() { clearTimeout(this._watchdog); if (this.closed || this.state === 'granted') return; this._watchdog = setTimeout(() => { if (this.closed || this.state === 'granted') return; console.warn('[MeshBay] no answer for transfer', this.tr.slice(0, 8), '- asking again'); this._request(); }, LEASE_WATCHDOG_MS); } _apply(msg) { if (this.closed) return; this.state = msg.state; this.ahead = msg.ahead || 0; this.used = msg.used; this.cap = msg.cap; if (msg.state === 'granted') { clearTimeout(this._watchdog); this._granted(); } else if (msg.state === 'closed') { // The node ended it: reclaimed as idle, or revoked. Not an error here — // whoever is running the transfer finds out through its own failure — but // the slot is gone and asking again is the only way back. clearTimeout(this._watchdog); } else { this._arm(); } if (this._onState) { try { this._onState(this); } catch (e) { console.error('[MeshBay] lease state handler threw:', e); } } } /** Resolves once the node has granted the slot. */ acquire() { return this._wait; } /** * Give the slot back. Safe to call twice, and safe on a dead transport: a * lease that is not released is a member who cannot start another transfer * until the node times it out, so this must never be conditional on anything. */ release(reason = 'done') { if (this.closed) return; this.closed = true; clearTimeout(this._watchdog); this.transport._leases.delete(this.tr); try { this.transport._send({ type: 'transfer_close', v: '0.1', tr: this.tr, reason }); } catch { /* the connection is gone, and so is the lease with it */ } } } class MeshBayTransport { constructor(hubUrl, accessToken) { this._hubUrl = hubUrl; this._accessToken = accessToken; this._pc = null; this._channel = null; this._pending = new Map(); // Set the first time this connection sees a reply that names the request // it answers (see _dispatch). A node either stamps every reply or none, // so one is proof for the connection — and once there is proof, the // arrival-order fallback at the bottom of _dispatch is never right again. this._correlates = false; this._seqId = 0; this._recvBuf = new Uint8Array(0); this._connected = false; this._onChat = null; this._onStreamInit = null; this._onStreamData = null; this._onStreamEnd = null; this._onStreamError = null; this._onIndexSync = null; // upload_id → the uploader waiting on it. Keyed rather than FIFO because // several uploads may be in flight at once and their acks interleave. // // It was keyed by filename until MNP 2.0, which is no longer possible: the // name is sealed under the group key, and echoing it in clear so the two // sides could match on it would give back precisely what the seal is for. // `upload_id` is drawn per upload here and is opaque to the node. this._uploaders = new Map(); // Names, not ids: the "already being uploaded" guard is about the file the // caller passed, and two `uploadFile` calls for one file draw two ids. this._inFlightUploads = new Set(); // tr → Lease. A transfer's slot on the node, from the client's side. this._leases = new Map(); // Set from the handshake ack: a node that answers with `transfer_limits` // speaks transfer slots. Used instead of a timeout, because "no answer // yet" and "this node will never answer" are indistinguishable in time and // guessing wrong either stalls every download or defeats the cap. this._transferLimits = null; // Set once close() runs — stops the automatic reconnect from firing on a // connection the caller tore down on purpose (leaving the group, page // unload), which would otherwise race back in right as everything else // is being torn down. this._closed = false; // The arguments connect() was last given, minus the token (refreshed at // reconnect time — see onNeedToken) and sessionKeys (kept live on `this`, // since a reconnect must reuse the identity connect() settled on, not // whatever the very first caller passed in — see _reconnectLoop). this._connectArgs = null; this._lastToken = null; this._reconnectPromise = null; this._reconnectAttempts = 0; // True only for the duration of the connect() call _reconnectLoop makes // to actually retry — as opposed to the backoff delay around it, which // is most of _reconnectPromise's lifetime. Needed because that connect() // call sends its own handshake through _sendAndWait, which would // otherwise see the very _reconnectPromise it is running inside of as // "a reconnect to wait for" and stall every handshake step for the full // 6s gate below before ever sending it. this._inReconnectAttempt = false; // A set, not one slot. Two consumers want this at once — the video // player, to re-ask for the stream it was watching, and the group // page, to re-read an index the node rebuilt while we were away — // and a single setter meant the second to arrive silently replaced // the first, then cleared it on the way out. this._reconnectListeners = new Set(); this._onNeedToken = null; // Which device key THIS connection has identified itself to the node with. // Empty means "not identified": nothing can be sealed, so nothing can be // posted to chat. Written only through _setDevicePk, which is what makes // the change visible to a reader — see onDeviceIdentity. this.devicePk = ''; this._onDeviceIdentity = null; // Cuts the backoff wait short the moment the page is foregrounded again — // found live to matter: a screen lock throttles the tab's own timers // along with everything else, so a backoff already counting down when the // phone locked can run for minutes of *wall clock* past its nominal delay // before it next gets to run at all. Set once, here, rather than inside // connect() like the diagnostic listener above it — this one has to // survive every reconnect attempt, not restart with each one. this._reconnectWakeResolve = null; this._onVisibilityWake = () => { if (document.visibilityState === 'visible') this._wakeReconnect(); }; document.addEventListener('visibilitychange', this._onVisibilityWake); } /** Cuts short a reconnect currently backing off (see _reconnectLoop). A * no-op when nothing is waiting, so this is safe to call unconditionally. */ _wakeReconnect() { if (this._reconnectWakeResolve) { this._reconnectWakeResolve(); this._reconnectWakeResolve = null; } } get connected() { return this._connected; } set onChat(fn) { this._onChat = fn; } set onStreamInit(fn) { this._onStreamInit = fn; } set onStreamData(fn) { this._onStreamData = fn; } set onStreamEnd(fn) { this._onStreamEnd = fn; } set onStreamError(fn) { this._onStreamError = fn; } set onIndexSync(fn) { this._onIndexSync = fn; } set onIndexDelta(fn) { this._onIndexDelta = fn; } set onRootsChanged(fn) { this._onRootsChanged = fn; } /** The MNP version the connected node declared, or '' before a handshake. */ get nodeVersion() { return this._nodeVersion || ''; } /** This member's own caps in this group, or null when the node said nothing. */ get transferLimits() { return this._transferLimits; } set onAppsEnabled(fn) { this._onAppsEnabled = fn; } set onAppDirectories(fn) { this._onAppDirectories = fn; } set onChatDirectory(fn) { this._onChatDirectory = fn; } set onChatLinkPreview(fn) { this._onChatLinkPreview = fn; } set onSearchListed(fn) { this._onSearchListed = fn; } set onChatEpoch(fn) { this._onChatEpoch = fn; } set onTmdbConfig(fn) { this._onTmdbConfig = fn; } set onTmdbEnabled(fn) { this._onTmdbEnabled = fn; } set onMusicbrainzEnabled(fn) { this._onMusicbrainzEnabled = fn; } set onIndexProgress(fn) { this._onIndexProgress = fn; } // Fired when a message that must open under the group key does not — // see _failSession. The session is over by the time this runs. set onSessionFailed(fn) { this._onSessionFailed = fn; } /** * Told once an automatic reconnect (see _reconnectLoop) lands a fresh * handshake, with that handshake's ack. * * Returns its own unsubscribe, because the caller that stops listening * must not be able to stop anyone else listening: `onReconnected` was a * setter, the video player took it on open and set it back to `null` on * close, and any other consumer's handler went with it. */ addReconnectListener(fn) { this._reconnectListeners.add(fn); return () => this._reconnectListeners.delete(fn); } /** * Told whenever this connection's device identity changes — including to * *nothing*, which is the case that mattered. * * `devicePk` is settled inside connect(), so a reconnect can clear it long * after the page last rendered. A reader that computed "can I post?" from the * field itself — chat-app.js did, through a ref — had no way to learn the * answer had changed, and the composer stayed disabled on a connection with * nothing whatever wrong with it and not one line in the console. */ set onDeviceIdentity(fn) { this._onDeviceIdentity = fn; } // Reconnecting redoes the handshake, which needs a JWT that may have gone // stale while the connection was down for minutes. Without this the // reconnect resends whatever token the original connect() call captured, // which the node's clock-skew check (stale_request) or plain expiry can // by then have already invalidated. Set to whatever the caller uses to // refresh the hub session token (see group-page.js's ensureFreshToken). set onNeedToken(fn) { this._onNeedToken = fn; } get sessionKeys() { return this._sessionKeys; } /** Set on a first join: the identity created for this node, still to be left with it. */ get newNodeBundle() { return this._newNodeBundle || null; } set newNodeBundle(v) { this._newNodeBundle = v; } /** The recovery-wrapped copy of that same first-join identity, when a recovery key was in hand. */ get newNodeBundleRecovery() { return this._newNodeBundleRecovery || null; } set newNodeBundleRecovery(v) { this._newNodeBundleRecovery = v; } /** * Tell a caller that connecting is getting somewhere. * * Only a caller that imposes its own deadline on `connect()` sets * `onConnectProgress`, and only Search does: everywhere else a connection is * opened one at a time and waits out the budgets in here. It exists so that * deadline can measure *stalling* rather than elapsed time — a link slow * enough to need twelve seconds is not the same thing as a node that is not * answering, and a fixed number cannot tell them apart. * * Never lets a caller's callback break the connection it is reporting on. */ _noteConnectProgress(phase) { if (!this.onConnectProgress) return; try { this.onConnectProgress(phase); } catch { /* the caller's problem */ } } async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username, userId, joinCode, recoveryKey, joinNodePk) { // Remembered for _reconnectLoop, which calls connect() again with these // same values (plus a freshly-fetched token and the identity connect() // itself settles on below) after the WebRTC connection is declared // "failed" — see the pc.onconnectionstatechange handler further down. this._connectArgs = { nodeId, groupId, gekRaw, bundleKey, username, userId, joinCode, recoveryKey, joinNodePk, }; this._lastToken = jwtToken; // The constructor sets this once from whatever token the caller had at // the time — and the signaling POST below reads *this*, not `jwtToken`. // A reconnect passes a freshly-fetched `jwtToken` (see onNeedToken) but // that never reached here before, so the signaling call kept using the // original token no matter how many minutes had passed or how many // reconnect attempts fetched a new one — confirmed live: every attempt // failed "Signaling failed: 401 Invalid or expired token" in a loop, // never actually trying the fresh token connect() had just been handed. this._accessToken = jwtToken; this._gekRaw = gekRaw || null; this._sessionKeys = sessionKeys || null; this._bundleKey = bundleKey || null; this._recoveryKey = recoveryKey || null; this._username = username || null; this._userId = userId || null; // The group this connection is for. Kept on the instance because the // handshake is not the only thing that needs it any more: device_hello and // the chat envelope both bind to it, and both run outside connect()'s scope. this._groupId = groupId || ''; this._newNodeBundle = null; this._newNodeBundleRecovery = null; this._joinError = null; // Per connection, for the same reason the chat keys and the roster are // dropped further down: the device the *previous* connection identified // itself with is not this one's, and leaving it standing is how a // reconnect that never got as far as announcing a device still looked, to // the composer, exactly like one that had. this._setDevicePk('', 'new connection'); this._pc = new RTCPeerConnection({ iceServers: await iceServers() }); this._channel = this._pc.createDataChannel('mnp', { ordered: true }); this._channel.binaryType = 'arraybuffer'; let channelReject = null; const channelReady = new Promise((resolve, reject) => { channelReject = reject; const timeout = setTimeout(() => reject(new Error('DataChannel open timeout')), 30000); this._channel.onopen = () => { clearTimeout(timeout); this._connected = true; trace('channel_open', {}); this._noteConnectProgress('channel_open'); resolve(); }; }); // Marks it handled, and nothing else — `await channelReady` below still // sees the rejection. Without it, a connect() that gives up earlier (at // signaling, say) leaves this promise with nobody attached, and closing the // peer connection then rejects it into an "Uncaught (in promise) // DataChannel closed" on the console. Every failed reconnect attempt // printed one, which is noise in exactly the log a freeze gets read from. channelReady.catch(() => { /* the awaiter below reports it */ }); this._channel.onmessage = (event) => this._onMessage(event.data); this._channel.onclose = (ev) => { console.warn('[MeshBay] DataChannel closed', this._channel?.readyState, ev); trace('channel_close', { readyState: this._channel?.readyState, pc: this._pc?.connectionState, ice: this._pc?.iceConnectionState, }); this._connected = false; if (channelReject) channelReject(new Error('DataChannel closed')); for (const [, p] of this._pending) p.reject(new Error('DataChannel closed')); this._pending.clear(); }; this._channel.onerror = (ev) => { console.error('[MeshBay] DataChannel error', ev); trace('channel_error', { pc: this._pc?.connectionState, ice: this._pc?.iceConnectionState, }); if (channelReject) channelReject(new Error('DataChannel error')); }; // Captured locally rather than read back through `this._pc`: once a // reconnect replaces it, a late event from this (by then orphaned) pc // must still be judged against the pc it actually came from, not // whatever is current — the `pc === this._pc` check below is what that // buys. const pc = this._pc; pc.onconnectionstatechange = () => { console.log('[MeshBay] PC state:', pc.connectionState); trace('pc_state', { state: pc.connectionState }); // "failed" is ICE's own verdict that nothing here will recover on its // own (unlike a transient "disconnected", which often clears itself) — // confirmed live: mobile screen lock for several minutes reliably // produces disconnected → failed about 10s apart, on both ends, and // nothing today ever moves past that without a full page reload. // `channel.readyState` is no help distinguishing this: it was observed // staying "open" throughout, so every send from here on would simply // sit out its own timeout instead of failing fast. if (pc.connectionState === 'failed' && pc === this._pc && !this._closed) { this._connected = false; this._reconnect(); const err = new Error('WebRTC connection lost'); err.name = 'TransportLostError'; for (const [, p] of this._pending) p.reject(err); this._pending.clear(); } }; pc.oniceconnectionstatechange = () => { console.log('[MeshBay] ICE state:', pc.iceConnectionState); trace('ice_state', { state: pc.iceConnectionState }); // `connected`/`completed` is the first moment this browser knows the peer // is there at all: a candidate pair answered. `checking` is not — it is // this side trying addresses that may all be dead. if (pc === this._pc && (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed')) { this._noteConnectProgress('ice_connected'); } }; // Diagnostic-only: a periodic health ping and a resume-triggered one, so // a trace captures exactly what state the connection was in right as the // page comes back from being backgrounded/locked — never active unless // trace mode is on (see TRACE_KEY above). // // connect() runs again on every reconnect attempt (see _reconnectLoop), // and each run used to add its own listener/interval on top of the // previous one without ever removing it — confirmed live: 8 failed // attempts during one screen lock left 8 duplicate `visibility` trace // lines firing off the same real event. Disposing of the prior instance // first is what keeps this to one. if (this._diagCleanup) { this._diagCleanup(); this._diagCleanup = null; } if (traceEnabled()) { const healthPing = async (reason) => { const before = { pc: this._pc?.connectionState, ice: this._pc?.iceConnectionState, channel: this._channel?.readyState, }; const start = Date.now(); try { await this.ping(8000); trace('health_ping', { reason, ok: true, rtt_ms: Date.now() - start, ...before }); } catch (e) { trace('health_ping', { reason, ok: false, error: String(e && e.message || e), elapsed_ms: Date.now() - start, ...before }); } }; const onVisibility = () => { trace('visibility', { state: document.visibilityState, pc: this._pc?.connectionState, ice: this._pc?.iceConnectionState, channel: this._channel?.readyState, }); if (document.visibilityState === 'visible' && this._channel?.readyState === 'open') { healthPing('resume'); } }; document.addEventListener('visibilitychange', onVisibility); const healthInterval = setInterval(() => { if (this._channel?.readyState === 'open') healthPing('interval'); }, TRACE_PING_INTERVAL_MS); this._diagCleanup = () => { document.removeEventListener('visibilitychange', onVisibility); clearInterval(healthInterval); }; } const offer = await this._pc.createOffer(); await this._pc.setLocalDescription(offer); // Wait for candidates, but not indefinitely. // // This is non-trickle signaling: the offer carries its candidates, so the // SDP is only sent once gathering is done. When gathering *never* finishes // — a STUN server that is slow, filtered, or being resolved through a DNS // that is not answering — this promise never settles, and joining a group // hangs with no error and nothing on screen. Reported after exactly that, // and it succeeded on a later attempt, which is the shape of a network // wait rather than a refusal. // // Past the deadline the offer goes out with whatever has been gathered. // Host candidates are already there, which is enough on a LAN — the case // this project cares most about — and the reflexive ones normally arrive // in well under a second when STUN is reachable at all. A partial offer // that usually connects beats a promise that never returns. await new Promise((resolve) => { if (this._pc.iceGatheringState === 'complete') return resolve(); const done = () => { clearTimeout(timer); resolve(); }; const timer = setTimeout(() => { console.warn('[MeshBay] ICE gathering did not finish in', ICE_GATHER_TIMEOUT_MS, 'ms — offering what we have'); done(); }, ICE_GATHER_TIMEOUT_MS); this._pc.onicegatheringstatechange = () => { if (this._pc.iceGatheringState === 'complete') done(); }; }); // Signaling is a hub call like any other, so it goes the same way — in the // application that means through the main process, because the renderer's // app:// origin is refused by CORS. const call = (window.MeshBayPlatform && window.MeshBayPlatform.apiFetch) || fetch; const resp = await postOffer(call, `${this._hubUrl}/v1/nodes/${nodeId}/webrtc/offer`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this._accessToken}`, }, body: JSON.stringify({ sdp: this._pc.localDescription.sdp, ice_candidates: [], }), }, { isClosed: () => this._closed, onRetry: (status, delayMs, attempt) => { console.warn('[MeshBay] Hub refused the offer with', status, '— sending it again in', delayMs, 'ms (attempt', attempt, ')'); trace('offer_retry', { status, delay_ms: delayMs, attempt }); // The hub answered: a busy hub, not a silent node. this._noteConnectProgress('offer_retry'); }, }); const answer = await resp.json(); this._rawAnswerSdp = answer.sdp; await this._pc.setRemoteDescription({ type: 'answer', sdp: answer.sdp }); await channelReady; console.log('[MeshBay] DataChannel open, sending handshake for group', groupId, 'channel=', this._channel?.readyState, 'crypto=', !!window.MeshBayCrypto); // The client nonce is what makes the NODE's proof fresh (C3) — without it a // recorded handshake_ack could be replayed by an impersonating peer. this._nonceClient = crypto.getRandomValues(new Uint8Array(32)); const reply = await this._sendAndWait({ type: 'handshake', v: MNP_V, v_min: MNP_V_MIN, token: jwtToken, group_id: groupId || '', nonce: window.MeshBayCrypto.b64encode(this._nonceClient), }); console.log('[MeshBay] Handshake reply:', reply.type); if (reply.type === 'handshake_challenge') { // The node's half of the range. Checked before anything else in this // block, because everything below — the join, the proof, the sealed ack // — assumes both sides mean the same thing by each message. _checkNodeVersion(reply); // Kept for diagnostics only. Nothing branches on it: the range check // above is what decides whether these two can talk at all, and a peer it // admits speaks every message in this file. this._nodeVersion = String(reply.v || ''); if (!window.MeshBayCrypto) { throw new Error('Node requires GEK proof but no crypto available'); } // Recorded the moment the challenge arrives, because everything below may // need them — joining, in particular, happens before the proof and signs a // transcript over both. Reading them further down, next to the proof that // also uses them, meant join_request ran with neither. // // nonce_node ties a join to this connection, so one cannot be lifted onto // another. node_pk is announced here because a first-time member has no // GEK and so cannot complete the handshake that would prove it. From an // older node it is unverified until the ack below checks it. this._nonceNode = window.MeshBayCrypto.b64decode(reply.nonce); this.nodePk = reply.node_pk || null; // Since MNP 3.4 the node signs its challenge over this connection, so // node_pk is proved here and not only at the ack — which comes after any // join. A signature that does not verify is a peer lying about which node // it is, and is refused. An absent one is an older node: `nodePkProved` // stays false, and whatever needs the key proved before a code leaves // (an invitation link names its node) reads that — never a version. this.nodePkProved = await _challengeProvesNodeKey( reply, groupId || '', this._nonceClient, this._pc.localDescription.sdp, this._rawAnswerSdp); // Our identity for THIS node: fetched from it, or created if this is a // first join. Keys are per node, so there is nothing to carry between // them — and an operator who cracks the copy on their own disk gets a key // that opens nothing anywhere else. let fresh = false; if (!this._sessionKeys && this._bundleKey && window.MeshBayKeys) { const kpResp = await this._sendAndWait({ type: 'keypair_bundle_fetch', v: '0.1', }); let keys = null; let openErr = null; if (kpResp.type === 'keypair_bundle_resp' && kpResp.found) { try { keys = await window.MeshBayKeys.decryptBundleWithKey( kpResp.bundle_enc, this._bundleKey); } catch (e) { openErr = e; // The passphrase key did not open the bundle. If we hold a recovery // key and the node kept a recovery copy, try that — Flow B // (docs/MESHBAY_DESIGN.md §3.6): recovering an identity after a lost // passphrase, before re-wrapping it under the new one. if (this._recoveryKey && kpResp.bundle_enc_recovery) { try { keys = await window.MeshBayKeys.decryptBundleWithKey( kpResp.bundle_enc_recovery, this._recoveryKey); this._recoveredFromRecovery = true; } catch { /* recovery copy did not open either */ } } } } if (!keys && this._rewrapOnly) { // A passphrase-change / backfill run must recover the *existing* // identity or report the node — never mint a new one. These strings // are shown on the reset / backfill screens. throw new Error( !kpResp.found ? 'no identity on this node' : this._recoveryKey ? (kpResp.bundle_enc_recovery ? "recovery key does not open this node's bundle" : 'no recovery copy on this node') : (openErr && openErr.message) || 'could not open the stored identity'); } if (keys) { const pkXB64 = await _pkFromSk(keys.skX); this._sessionKeys = { skXB64: keys.skX, skEdB64: keys.skEd, pkXB64 }; } else { // Either the node has never seen us, or it holds a stale bundle we // cannot open (wrapped under a passphrase we no longer use, with no // usable recovery copy — e.g. an unpin that left the old bundle // behind). Mint a fresh identity and let the join path take over; a // successful join overwrites whatever was stored. A recovery-wrapped // copy is left too when a recovery key is in hand (§4.3). const id = await window.MeshBayKeys.generateNodeIdentity( this._bundleKey, this._recoveryKey); this._sessionKeys = { skEdB64: id.skEdB64, skXB64: id.skXB64, pkXB64: id.pkXB64, }; this._newNodeBundle = id.bundleEnc; this._newNodeBundleRecovery = id.bundleEncRecovery || null; fresh = true; } } // An identity this node already knows still needs its group key, which the // node wraps on every connection. if (!gekRaw && this._sessionKeys && !fresh) { const bundleResp = await this._sendAndWait({ type: 'gek_bundle_fetch', v: '0.1', }); if (bundleResp.type === 'gek_bundle_resp' && bundleResp.found) { const skXRaw = Uint8Array.from(atob(this._sessionKeys.skXB64), c => c.charCodeAt(0)); const myPkX = Uint8Array.from(atob(this._sessionKeys.pkXB64), c => c.charCodeAt(0)); try { gekRaw = await window.MeshBayCrypto.unwrapGEK(bundleResp, skXRaw, myPkX); this._gekRaw = gekRaw; } catch (e) { console.warn('[MeshBay] stored GEK bundle did not open; joining instead'); } } } // No stored bundle: ask the node to recognise us and wrap the key itself. // This is the normal path for anyone who joined after the invite redesign — // no bundle is pre-stored for members any more. A code is needed only the // first time this node sees this account. // A code from an invitation link goes to the node the link names and to // no other, and only once that node has proved the key in its challenge // (docs/MESHBAY_DESIGN.md §3.4). Otherwise nothing is sent at all — not // even a join without the code, which this node would answer by asking // for one. const linkRefusal = _linkJoinRefusal(joinNodePk, joinCode, this.nodePk, this.nodePkProved); if (linkRefusal) { this._joinError = linkRefusal; } else if (!gekRaw && this._sessionKeys && userId) { try { gekRaw = await this.joinGroup(userId, groupId, joinCode); } catch (e) { // The UI turns this into "ask the operator for an invite code". this._joinError = e; } } if (!gekRaw && !this._sessionKeys) { // No key in this browser to sign or unwrap with — `bundleKey` was null. // The caller (group-page.js) shows a passphrase prompt on this reason // and retries; a code prompt would be useless, since a code proves who // you are and there is no key to bind it to. const err = new Error('Your passphrase is needed to unlock your keys in this browser.'); err.reason = 'no_keys'; throw err; } if (!gekRaw) { throw this._joinError || new Error('Node requires GEK proof but no GEK available'); } const C = window.MeshBayCrypto; // Node's answer SDP carries ITS fingerprint; our offer carries ours. Throws // if either is missing rather than proceeding with an unbound proof (L4). const binding = C.webrtcBinding( _extractDtlsFingerprint(this._pc.localDescription.sdp), _extractDtlsFingerprint(this._rawAnswerSdp), ); const nonceNode = this._nonceNode; // captured when the challenge arrived const gid = groupId || ''; const proof = await C.handshakeProof( gekRaw, 'client', gid, this._nonceClient, nonceNode, binding); const ack = await this._sendAndWait({ type: 'handshake_response', v: '0.1', proof: C.b64encode(proof), }); if (ack.type !== 'handshake_ack') { throw new Error('GEK proof rejected: ' + (ack.detail || JSON.stringify(ack))); } // Authenticate the NODE before trusting anything it says (C3). Until this // ran, node_pk was decorative: a peer that had hijacked signaling could // accept our proof, ignore it, and serve a forged index, chat history and // is_node_admin flag. const expected = await C.handshakeProof( gekRaw, 'node', gid, this._nonceClient, nonceNode, binding); if (!ack.proof || !C.constantTimeEqual(C.b64decode(ack.proof), expected)) { throw new Error('Node failed to prove GEK possession — refusing connection'); } const transcript = C.handshakeTranscript( 'node', gid, this._nonceClient, nonceNode, binding); if (!ack.node_pk || !ack.sig || !await C.verifyNodeSignature(ack.node_pk, ack.sig, transcript)) { throw new Error('Node signature invalid — refusing connection'); } // Trust On First Use (11.5.8). With C6 closed, a substituted node already // fails the GEK proof — this covers the case where an attacker HAS the GEK // (an ex-member, or a leaked key) and swaps the node underneath. // Strict refusal: a warning users can click through is decorative. // The key announced in the challenge must be the one that just proved // itself. A peer that changed identity mid-handshake is not one to trust // with anything, including a join we may already have signed for it. if (this.nodePk && this.nodePk !== ack.node_pk) { throw new Error('Node identity changed during the handshake — refusing'); } _checkNodePin(nodeId, ack.node_pk); this.nodePk = ack.node_pk; // Verify, then decrypt — in that order, and the order is the point. Every // check above decides whether this peer is worth trusting at all; opening // the payload first would mean acting on data from a peer we have not yet // authenticated. // // A payload that does not open aborts the connection. It is emphatically // not an empty config: `enabled_apps` missing reads as "the operator // disabled every app" (the documented client-side fallback is the // opposite — show them all), and either reading is indistinguishable from // a legitimate state, which is what makes a silent fallback worse than a // stop. let config; try { config = msgpack_decode( await C.openGroup(gekRaw, 'ack', 'handshake_ack', gid, ack)); } catch (e) { throw new Error( 'handshake_ack did not open under the group key — refusing connection: ' + (e && e.message || e)); } delete ack.nonce; delete ack.ct; Object.assign(ack, config); this._transferLimits = ack.transfer_limits || null; // From the *sealed* part of the ack: a forged epoch would have this // client sealing under a key the group has retired. this.chatEpoch = ack.chat_epoch || 0; // Per connection: a reconnect may land on a node whose epoch has moved, // and keeping a stale set would silently seal under a retired key. this._chatKeys = null; this._chatKeysInFlight = null; this._roster = null; this._rosterInFlight = null; // Tell the node which of this account's devices is on this connection, // after the ack and unconditionally: a peer `check_version` admitted // speaks this message, and identifying the device is what makes chat // possible at all. await this._announceDevice().catch((e) => { console.warn('[MeshBay] device_hello failed — chat will not work:', e); }); return ack; } // A node that answers a handshake with anything other than a challenge is not // running the mutual protocol. Accepting a bare handshake_ack here would let a // peer skip proving GEK possession entirely (C3/C6). console.warn('[MeshBay] Handshake rejected:', reply.detail, 'code:', reply.code); const rejected = new Error( HANDSHAKE_REFUSALS[reply.code] || ('MNP handshake rejected: ' + (reply.detail || `unexpected ${reply.type}`))); rejected.reason = reply.code || ''; throw rejected; } /** * Record — and announce — the device key this connection is identified by. * * Every write to `devicePk` goes through here, for two reasons: it is traced, * so a session that ends up unable to post says so and says when; and it * tells the page, which had no other way to find out that the answer moved. */ _setDevicePk(pk, why) { const next = pk || ''; if (next === this.devicePk) return next; this.devicePk = next; console.log('[MeshBay] device identity:', next ? 'identified' : 'NOT identified — chat cannot post', '(' + why + ')'); trace('device_identity', { identified: !!next, why }); if (this._onDeviceIdentity) { try { this._onDeviceIdentity(!!next); } catch (e) { console.error('[MeshBay] onDeviceIdentity handler threw:', e); } } return next; } /** * "This connection is device X of account Y", signed with the device key. * * Best effort by construction: a browser that has not recovered its identity * keys has nothing to sign with. What it is *not* is silent — and it had * three exits that were. Two early returns left whatever the previous * connection had settled on standing; and a reply that is not * `device_hello_ack` wiped the key without a word, because an `error` reply * does not throw and so never reached the `.catch()` at the call site. The * result is a chat that cannot post on a connection with nothing else wrong * with it, which is unreadable from the outside — the shape of the "chat * hangs, the textbox is dead" report. Every exit below names itself. */ async _announceDevice() { if (!this._sessionKeys || !this._sessionKeys.skEdB64) { return this._setDevicePk('', 'no identity key in this session'); } if (!this._nonceNode || !this.nodePk || !this._userId) { return this._setDevicePk('', 'handshake state incomplete'); } const C = window.MeshBayCrypto; // Derived from our own secret key, never read back from anywhere — the same // rule as pairOperator: signing a public key someone handed us is the // substitution this mechanism exists to close. const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64); const ts = Math.floor(Date.now() / 1000); const transcript = C.deviceHelloTranscript( this.nodePk, this._groupId || '', this._userId, pkEdB64, this._nonceNode, ts); const sig = await window.MeshBayKeys.signBytes( this._sessionKeys.skEdB64, transcript); const resp = await this._sendAndWait({ type: 'device_hello', v: '2.0', pk_ed25519: pkEdB64, ts, sig, }); if (!resp || resp.type !== 'device_hello_ack') { return this._setDevicePk('', 'node answered ' + ((resp && resp.type) || 'nothing') + ((resp && resp.detail) ? ': ' + resp.detail : '')); } return this._setDevicePk(pkEdB64, 'device_hello_ack'); } /** * Kick off (or join, if one is already running) the automatic reconnect * after the WebRTC connection is declared unrecoverable. Idempotent: every * caller racing to reconnect at once — the connectionstatechange handler, * and any request that lands in the gap _sendAndWait waits out below — * shares the one attempt instead of piling up parallel handshakes against * the node. */ _reconnect() { if (this._closed) return Promise.resolve(); if (!this._reconnectPromise) { this._reconnectPromise = this._reconnectLoop().finally(() => { this._reconnectPromise = null; }); } return this._reconnectPromise; } /** * Redo the signaling handshake from scratch — the only thing that works * once aiortc has declared a connection "failed": the node discards that * session the moment it sees the same state (webrtc_server.py's * on_state_change), so there is no lower-level session left to resume, only * a fresh one to negotiate. Retries with capped exponential backoff * (1s, 2s, 4s ... 30s) rather than a fixed number of attempts, because the * two real causes seen so far — a mobile carrier dropping the NAT mapping * during screen lock, and the node's own machine being briefly unreachable * — both resolve on their own eventually, and there is no good moment to * decide the user would rather see a dead app than keep waiting. */ async _reconnectLoop() { this._reconnectAttempts = 0; while (!this._closed) { this._reconnectAttempts += 1; const delayMs = Math.min(30000, 1000 * 2 ** (this._reconnectAttempts - 1)); trace('reconnect_wait', { attempt: this._reconnectAttempts, delay_ms: delayMs }); // Interruptible: _wakeReconnect (fired on visibilitychange → visible) // resolves this immediately instead of waiting out the rest of a // backoff that was mostly spent while nothing could succeed anyway. await new Promise((resolve) => { const timer = setTimeout(resolve, delayMs); this._reconnectWakeResolve = () => { clearTimeout(timer); resolve(); }; }); this._reconnectWakeResolve = null; if (this._closed) return; try { // Best-effort: these are already unusable, but leaving them wired up // risks a stray late event from the old pc doing something once a // new one is in `this._pc` — the `pc === this._pc` guard above closes // most of that gap, this closes the rest. try { this._channel && this._channel.close(); } catch { /* already gone */ } try { this._pc && this._pc.close(); } catch { /* already gone */ } const args = this._connectArgs; const token = this._onNeedToken ? await this._onNeedToken() : this._lastToken; trace('reconnect_attempt', { attempt: this._reconnectAttempts }); this._inReconnectAttempt = true; let ack; try { ack = await this.connect(args.nodeId, token, args.groupId, args.gekRaw, this._sessionKeys, args.bundleKey, args.username, args.userId, args.joinCode, undefined, args.joinNodePk); } finally { this._inReconnectAttempt = false; } trace('reconnect_ok', { attempt: this._reconnectAttempts }); console.log('[MeshBay] Reconnected after', this._reconnectAttempts, 'attempt(s)'); // Before the caller's own hook: a transfer that resumes mid-chunk must // have asked for its slot back first, or its next `file_req` carries a // `tr` the node has never heard of. this._reopenTransfers(); // The ack goes with it: this is a *new* session against whatever the // node is running now, and everything the first handshake taught the // page — the folders each app reads, which apps are on, the roots — // was answered by a process that may since have restarted. One // listener throwing must not rob the next of the notification. for (const fn of [...this._reconnectListeners]) { try { fn(ack); } catch (e) { console.error('[MeshBay] reconnect listener threw:', e); } } return; } catch (e) { trace('reconnect_attempt_failed', { attempt: this._reconnectAttempts, error: String(e && e.message || e), }); console.warn('[MeshBay] Reconnect attempt', this._reconnectAttempts, 'failed:', e.message); // Loop again with a longer backoff — closing over `args`/`token` // freshly next time, in case the token was the actual problem. } } } /** * The full index. Resolves with the sealed payload already opened — * `_applyIndexMessage` does that before it hands the message to whoever is * waiting, so both the reply to this call and the node's own unsolicited * pushes go through one decrypt path. */ async fetchIndex() { const msg = await this._sendAndWait({ type: 'index_sync', v: '0.1' }); if (msg.type === 'error') throw new Error(msg.detail); return msg; } // ── Transfer slots ───────────────────────────────────────────────────────── /** * Ask the node for a slot, and wait until it says yes. * * `tr` is drawn here, not by the node — 16 random bytes, exactly like * `upload_id` — which is what makes re-opening after a reconnect idempotent * rather than a second charge against the member's cap. * * On a node that predates transfer slots this resolves at once and costs * nothing: there is no cap to respect and no message that would be * understood. */ openTransfer({ kind = 'download', bytes = 0, chunks = 0, onState = null } = {}) { const tr = _hex(crypto.getRandomValues(new Uint8Array(16))); const lease = new Lease(this, tr, kind, bytes, chunks, onState); this._leases.set(tr, lease); lease._request(); return lease; } /** Re-ask for every live lease. Called after a reconnect. */ _reopenTransfers() { for (const lease of this._leases.values()) { // The node lost the lease with the session, so this is a fresh request // for the same `tr` — which the node treats as the same transfer rather // than a second one. if (!lease.closed) lease._request(); } } async fetchChunk(fileId, chunkIndex, tr = '') { const msg = await this._sendAndWait({ type: 'file_req', v: '0.1', file_id: fileId, chunk_index: chunkIndex, // Present only when this download holds a slot. The node does not require // it yet; carrying it is what lets the node see the transfer is alive and // not reclaim its slot as idle. ...(tr ? { tr } : {}), }); if (msg.type === 'error') throw new Error(msg.detail); return msg; } /** * Liveness on this already-open channel. Resolves with the round trip in ms, * rejects on timeout — a DataChannel whose peer vanished without closing * still reads as connected, and nothing else here notices until a real * request hangs. */ async ping(timeoutMs = 5000) { const token = Math.random().toString(36).slice(2); const started = performance.now(); const msg = await this._sendAndWait({ type: 'ping', v: '0.2', token }, timeoutMs); if (msg.type === 'error') throw new Error(msg.detail); return Math.round(performance.now() - started); } // // Identity keys are per node, so a browser and a desktop client are two keys // on one account here. A new one is admitted by a key this node already // pinned — never by the hub, which holds no user keys and so cannot // countersign anything. See docs/MESHBAY_DESIGN.md §3.3. // // MNP 3.1, docs/playlists.md §8.1. The node stores bytes it cannot read and // hands them back; `user_id` is never sent, because the node takes it from // the authenticated session and would be wrong to take it from here. // // `blob_enc` is a Uint8Array and goes on the wire as msgpack `bin`, not // base64: a playlist runs to hundreds of kilobytes and base64 is a third of // every write. None of these needs an entry in the `ack` fallback at the // bottom of _handleMessage — that branch exists for nodes too old to stamp // `req_id`, and no node old enough to skip it knows these messages at all. get gekRaw() { return this._gekRaw; } /** * Give an automatic reconnect already in progress (see _reconnectLoop) a * bounded chance to land before giving up. * * _sendAndWait does this internally for every request that goes through * it, so most callers never need this directly. It exists for the ones * that check `transport.connected` themselves before doing anything else — * music-player.js's fetchTrackBlob is the one this was written for: found * live throwing "Transport not connected" on the track *after* a * screen-lock reconnect had already been under way for a while, because * that check ran, saw `connected` still false, and threw before the * reconnect it only had to wait a few seconds for got the chance to finish. * A no-op — returns immediately — when nothing is being reconnected, * including once one has already succeeded, so it is safe to call * unconditionally ahead of such a check. */ async waitForReconnect(timeoutMs = 6000) { if (!this._reconnectPromise) return; await Promise.race([ this._reconnectPromise.catch(() => {}), new Promise((r) => setTimeout(r, timeoutMs)), ]); } close() { // Must be set before pc.close() below: that close() itself can drive the // pc to "closed" synchronously, and the connectionstatechange handler // only skips reconnecting because of this flag, not because "closed" is // absent from its own trigger condition. this._closed = true; document.removeEventListener('visibilitychange', this._onVisibilityWake); this._wakeReconnect(); if (this._diagCleanup) { this._diagCleanup(); this._diagCleanup = null; } if (this._channel) this._channel.close(); if (this._pc) this._pc.close(); this._connected = false; for (const [, p] of this._pending) p.reject(new Error('Transport closed')); this._pending.clear(); } // ── Internal ────────────────────────────────────────────────────────────── /** * Queue one sealed index message for opening. * * Opening is asynchronous and `_dispatch` is not, so two messages handled * independently would be applied in whichever order their decrypt promises * happened to settle. A delta applied before the sync it is based on — or * before an earlier delta — is a silently wrong view of the group, so they * are opened one at a time, in arrival order. */ _queueIndexMessage(msg) { this._indexChain = (this._indexChain || Promise.resolve()) .then(() => this._applyIndexMessage(msg)) .catch((e) => this._failSession( `${msg.type} did not open under the group key`, e)); } async _applyIndexMessage(msg) { const groupId = msg.group_id || (this._connectArgs && this._connectArgs.groupId) || ''; const payload = msgpack_decode(await window.MeshBayCrypto.openGroup( this._gekRaw, 'index', msg.type, groupId, msg)); // The routing fields stay, the envelope's own two go, the payload lands on // top — so every consumer keeps reading the flat message it always read. const opened = { ...msg, ...payload }; delete opened.nonce; delete opened.ct; if (msg.type === 'index_sync') { if (this._onIndexSync) this._onIndexSync(opened); // The node's first push to a newly connected peer is an index_sync // nobody asked for, so there is not always a request to resolve. When // there is, `req_id` says which one — the type match below is what a // node too old to stamp one leaves us, and it is why two fetches in // flight at once used to resolve the wrong one. const handler = opened.req_id !== undefined && opened.req_id !== null ? this._pending.get(opened.req_id) : [...this._pending.values()].find(h => h._reqType === 'index_sync'); if (handler) handler.resolve(opened); return; } if (this._onIndexDelta) this._onIndexDelta(opened); } /** * Stop, rather than carry on with a degraded view. * * A payload that does not open is not an empty index and not a config * change — it is a peer we cannot talk to. Reconnecting would only reach the * same peer with the same key, so the session ends and the failure is named. */ _failSession(what, cause) { const err = new Error(`${what}: ${(cause && cause.message) || cause}`); console.error('[MeshBay]', err.message); for (const [, handler] of this._pending) handler.reject(err); this._pending.clear(); if (this._onSessionFailed) this._onSessionFailed(err); this.close(); } async _sendAndWait(obj, timeoutMs = 30000) { // A reconnect already in flight (see _reconnectLoop) means the channel // this would send on is the one just declared dead. `_inReconnectAttempt` // excludes the handshake connect() itself makes while reconnecting — that // call runs *inside* this same _reconnectPromise, which cannot resolve // until it returns, so waiting on it here would just be waiting on // itself for the full 6s, on every step of the handshake, every time. if (!this._inReconnectAttempt) await this.waitForReconnect(6000); return new Promise((resolve, reject) => { const id = this._seqId++; const timeout = setTimeout(() => { this._pending.delete(id); console.error('[MeshBay] Response timeout for', obj.type, 'after', timeoutMs, 'ms, channel=', this._channel?.readyState); trace('send_timeout', { reqType: obj.type, timeoutMs, pc: this._pc?.connectionState, ice: this._pc?.iceConnectionState, channel: this._channel?.readyState, }); reject(new Error('Response timeout')); }, timeoutMs); this._pending.set(id, { _reqType: obj.type, // Chunks are the one request that runs several at a time and can be // interleaved with anything else on the channel. Matching them by // arrival order was only ever true by luck; this makes it true. // // A ping is keyed for the same reason and a sharper one: it is sent // *while* other traffic is in flight, so the fallback below would hand // a pong to whatever was waiting — resolving a history request with a // message that has no messages in it, and emptying the conversation. // media_meta_req is the same shape as file_req: video-app.js fires // one per visible poster-grid tile, several at a time — matching by // arrival order handed one tile's TMDB result to a different tile // whenever two responses reordered (reproduced live: which of two // shows got the confident match flipped across reloads). _key: obj.type === 'file_req' ? `chunk:${obj.file_id}:${obj.chunk_index}` : obj.type === 'ping' ? `ping:${obj.token}` : obj.type === 'media_meta_req' ? `media_meta:${obj.file_id}` // One chat message can carry several links, each unfurled on its // own; matching by arrival order would swap two cards. : obj.type === 'link_preview_req' ? `link_preview:${obj.url}` // Same reordering hazard as media_meta_req: an album grid fires // one music_meta_req per visible tile, several at a time. : obj.type === 'music_meta_req' ? `music_meta:${obj.file_id}` // Same reordering hazard as media_meta_req: a season-tab bar or a // search box can have more than one of these in flight at once. : obj.type === 'season_meta_req' ? `season_meta:${obj.tmdb_id}:${obj.season}` : obj.type === 'tmdb_search_req' ? `tmdb_search:${obj.media_type}:${obj.query}` // Same reordering hazard as media_meta_req: the player prefetches // the next track while the current one may still be transcoding. : obj.type === 'audio_transcode_req' ? `audio_transcode:${obj.file_id}` // The track ordinal is part of the key, not just the file id: a // viewer who opens the menu and picks a second language before the // first extraction has answered has two of these in flight for the // same film, and the one that arrives first is not necessarily the // one that was asked for first. : obj.type === 'subtitle_req' ? `subtitle:${obj.file_id}:${obj.track}` // Two-step admin-op flow (_authorizeAdminOp) — see ADMIN_OP_TYPES' // own comment for the race this closes. The initial request and // the admin_response that follows it are keyed the same way // (`admin:${op}`) precisely so a reply belongs to the request // that named that op, not to whichever admin op happened to be // submitted first. : ADMIN_OP_TYPES.has(obj.type) ? `admin:${obj.type}` : obj.type === 'admin_response' ? `admin:${obj.op}` : null, resolve: (msg) => { clearTimeout(timeout); this._pending.delete(id); resolve(msg); }, reject: (err) => { clearTimeout(timeout); this._pending.delete(id); reject(err); }, }); // The id goes on the wire (MNP 1.1+): a node that understands it stamps // the reply with it, and _dispatch matches on that alone. It used to be // local to this map, which is why every reply had to be recognised by // some field of its own — and why the ones that carry no such field // reached their caller by luck. An older node ignores the extra key and // is routed by the per-type fallbacks below, exactly as before. // `_send` throws synchronously when the channel is not open. Rejecting // on that is right, but the pending entry and its 30s timer were left // behind — so a request that never reached the wire still logged a // "Response timeout" half a minute later, for a reply nobody was owed. try { this._send({ ...obj, req_id: id }); } catch (e) { clearTimeout(timeout); this._pending.delete(id); reject(e); } }); } _send(obj) { if (!this._channel || this._channel.readyState !== 'open') { throw new Error(`DataChannel not open (state: ${this._channel?.readyState})`); } const encoded = msgpack_encode(obj); const header = new Uint8Array(4); new DataView(header.buffer).setUint32(0, encoded.byteLength, false); const frame = new Uint8Array(4 + encoded.byteLength); frame.set(header); frame.set(encoded, 4); this._channel.send(frame); } _onMessage(data) { const incoming = new Uint8Array(data); this._msgCount = (this._msgCount || 0) + 1; if (this._msgCount <= 3) { console.log('[MeshBay] recv', incoming.length, 'bytes, msg #' + this._msgCount); } const combined = new Uint8Array(this._recvBuf.length + incoming.length); combined.set(this._recvBuf); combined.set(incoming, this._recvBuf.length); this._recvBuf = combined; while (this._recvBuf.length >= 4) { const len = new DataView(this._recvBuf.buffer, this._recvBuf.byteOffset).getUint32(0, false); if (this._recvBuf.length < 4 + len) break; const msgBytes = this._recvBuf.slice(4, 4 + len); this._recvBuf = this._recvBuf.slice(4 + len); const msg = msgpack_decode(msgBytes); this._dispatch(msg); } } _dispatch(msg) { // A reply that names the request it answers. Nothing below this needs to // recognise it, and nothing below this may see it: every remaining branch // exists to identify a reply by some field of its own, which is the job // this makes unnecessary. // // What is left underneath is genuinely unsolicited — a broadcast to every // connected client, a push, a challenge — or a reply from a node too old // to stamp one, which is what the per-type keys are for now. if (msg.req_id !== undefined && msg.req_id !== null) { this._correlates = true; // The one exception, and the only one: an index message is sealed under // the GEK and cannot be handed to its caller until it is opened, which // is not something this synchronous function can do. Resolving it here // would give `fetchIndex` the envelope — nonce and ciphertext, no // entries — and skip `_onIndexSync` entirely. `_queueIndexMessage` // opens it and then resolves, by this same id. const sealed = msg.type === 'index_sync' || msg.type === 'index_delta'; if (!sealed) { const handler = this._pending.get(msg.req_id); if (handler) { handler.resolve(msg); // The acks whose *broadcast* half their own requester also needs: // every other client learns the change from the broadcast, and the // one that asked for it is the only one that would not, because its // own request swallowed its copy. Same call the keyed `_ack` branch // below makes, for the same reason. if (BROADCAST_ACK_TYPES.has(msg.type)) _replayBroadcast(this, msg); return; } // Answers a request that is no longer waiting: it gave up at its own // timeout, or a reconnect rejected everything in flight. It belongs to // nobody, and the whole point of this change is that it is not offered // to somebody else instead. console.warn('[MeshBay] late reply to req', msg.req_id, '(', msg.type, ') — nothing waiting'); return; } } // Two-step admin-op flow (_authorizeAdminOp, ADMIN_OP_TYPES) — resolve // by (op) key before anything below gets a chance to steal it via the // generic "oldest pending" fallback further down. Returns as soon as a // match resolves: this transport instance is the one that submitted // the request, and its own caller already updates local state from // what *it* sent (setAppsEnabled/setAppDirectories/... callers all do // `onX(next)` with their own local value, never by reading the ack), // so the broadcast-oriented per-type handlers below — there for every // *other* connected client learning the change — have nothing left to // add for this one. A message nobody here is waiting on (the common // case: this key match finds nothing) falls through exactly as before. if (msg.type === 'admin_challenge' && msg.op) { // Unlike an *_ack, this one is never a broadcast — the node only // ever sends it as a private reply to whichever session just // submitted the op it names (_issue_admin_challenge, one `self._send` // call, no peer loop) — so a session with no matching key genuinely // has nothing further to do with it either, and falling through to // "oldest pending" here can only ever be wrong, never a fallback // that happens to be right. const key = `admin:${msg.op}`; let matched = false; for (const [, handler] of this._pending) { if (handler._key === key) { handler.resolve(msg); matched = true; break; } } if (!matched) { // Should not happen — every caller that can receive this type keys // its own request the same way. Logged rather than silently // dropped (the old fallback below at least warned, however wrongly // it guessed) so a real mismatch is still visible instead of // looking exactly like the request never left the browser at all. console.warn('[MeshBay] admin_challenge for op=', msg.op, 'op_id=', msg.op_id, 'matched no pending request (pending keys:', [...this._pending.values()].map(h => h._key), ')'); } return; } else if (typeof msg.type === 'string' && msg.type.endsWith('_ack')) { const key = `admin:${msg.type.slice(0, -4)}`; for (const [, handler] of this._pending) { if (handler._key === key) { handler.resolve(msg); // The comment above ("its own caller already updates local state // from what it sent") is true of every op whose caller passes the // value it just chose to an onX(next). The root ops are not like // that: what changes is the whole roots table, which only the node // can compute — availability, the eject that the plug refused, the // name it settled on. Returning here left the operator who clicked // Eject as the one client that never saw it happen, while every // other peer got the broadcast. So this one type is handed on. if (BROADCAST_ACK_TYPES.has(msg.type)) _replayBroadcast(this, msg); return; } } } // While an upload is in flight the acks are its own, and there are many of // them: they must not be handed to whatever request happens to be oldest in // the pending map. if (msg.type === 'file_upload_ack' && this._uploaders.has(msg.upload_id)) { this._uploaders.get(msg.upload_id)(msg); return; } // An upload refusal names the upload it is about, so only that upload // fails. It did not use to, and there was no way to tell whose error it // was, so every upload in flight was failed together — send a second file // whose name the node dislikes and both died. The broadcast is kept for a // refusal that names none, where guessing wrong is worse than stopping. if (msg.type === 'error' && this._uploaders.size) { if (msg.upload_id && this._uploaders.has(msg.upload_id)) { this._uploaders.get(msg.upload_id)(msg); return; } if (!msg.upload_id) { for (const handler of [...this._uploaders.values()]) handler(msg); return; } // Named, but for an upload that is no longer running — not ours to act on. return; } if (msg.type === 'chat_msg' && this._onChat) { // Shaped through the same reader as history, and asynchronously — a live // message and a stored one are the same message, and a second way of // reading one is a second thing to get wrong. this._openChatMessage(msg) .then(m => { if (this._onChat) this._onChat(m); }) .catch(e => console.warn('[MeshBay] chat message unreadable', e)); return; } if (msg.type === 'stream_init') { console.log('[stream] recv stream_init, start:', msg.start, 'codec:', msg.codec, 'handler:', !!this._onStreamInit); if (this._onStreamInit) this._onStreamInit(msg); return; } if (msg.type === 'stream_data') { if (this._onStreamData) this._onStreamData(msg); return; } if (msg.type === 'stream_end') { console.log('[stream] recv stream_end'); if (this._onStreamEnd) this._onStreamEnd(msg); return; } // The operator changed which apps are shown, and everyone // connected hears about it without reconnecting. if (msg.type === 'apps_enabled_ack' && this._onAppsEnabled) { this._onAppsEnabled(msg.apps || []); } // An application was pointed at different folders. One handler for every // app — the callback is given the app's name and decides. if (msg.type === 'app_directories_ack' && this._onAppDirectories) { this._onAppDirectories(msg.app, msg.directories || []); } if (msg.type === 'chat_directory_ack' && this._onChatDirectory) { this._onChatDirectory(msg.path || ''); } if (msg.type === 'chat_link_preview_ack' && this._onChatLinkPreview) { this._onChatLinkPreview(Boolean(msg.enabled)); } if (msg.type === 'search_listed_ack' && this._onSearchListed) { this._onSearchListed(msg.listed !== false); } if (msg.type === 'chat_epoch_ack') { this._applyChatEpoch(msg); return; } // Node-wide (not per-group) — the operator supplied/cleared a custom // token, or changed the query language. `token_customized` only says // whether one is set, never the token itself. if (msg.type === 'tmdb_config_ack' && this._onTmdbConfig) { this._onTmdbConfig({ tokenCustomized: Boolean(msg.token_customized), language: msg.language || '', }); } // Per-group (2026-08-24, used to be folded into tmdb_config_ack above) — // the operator turned TMDB on/off for this group specifically. if (msg.type === 'tmdb_enabled_ack' && this._onTmdbEnabled) { this._onTmdbEnabled(Boolean(msg.enabled)); } // Same shape: an operator corrected a wrong automatic TMDB match, and // everyone connected needs to know their poster grid/detail modal for // this show is now stale — falls through so the operator's own // admin_response promise resolves on this same message, exactly like // member_upload_ack/apps_enabled_ack above. if (msg.type === 'tmdb_override_ack' && this._onTmdbOverride) { this._onTmdbOverride({ fileId: msg.file_id || '', tmdbId: msg.tmdb_id || '', mediaType: msg.media_type || '', }); } // Same shape: the operator dropped one file's match to have it // re-resolved (V13). No tmdbId — the node re-derives it. if (msg.type === 'tmdb_rematch_ack' && this._onTmdbOverride) { this._onTmdbOverride({ fileId: msg.file_id || '', tmdbId: '', mediaType: '' }); } // Per-group, like tmdb_enabled_ack above. if (msg.type === 'musicbrainz_enabled_ack' && this._onMusicbrainzEnabled) { this._onMusicbrainzEnabled(Boolean(msg.enabled)); } // A root's flags changed, or one was ejected, plugged, added or removed. // Broadcast by the node to every peer, so everyone's table updates without // waiting for the next index_sync. if (msg.type === 'root_update_ack' || msg.type === 'root_eject_ack' || msg.type === 'root_plug_ack' || msg.type === 'root_add_ack' || msg.type === 'root_remove_ack') { if (this._onRootsChanged) this._onRootsChanged(msg); } // The operator's node is scanning — never the entries themselves, just // enough to animate a presence dot. Pushed periodically while it runs, // plus once more on the transition back to idle (daemon.py // _progress_pusher). UNLIKE member_upload_ack/apps_enabled_ack above, // this is never a reply to anything this browser asked for — nobody // calls _sendAndWait for it — so it MUST return here. Falling through // to the "oldest pending" guess below hands it to whatever unrelated // request happens to be waiting (a handshake, a chat history fetch), // which then waits forever for its real answer while this one already // "arrived" — and every message after that is one slot off too. Found // live: a group mid-scan corrupted its own handshake and chat history // this way, arriving roughly every 2s for as long as scanning ran. // Routed by `tr`, and only by `tr`. A grant arrives unsolicited, minutes // after the request that produced it, so falling through to "the oldest // pending request" would hand a chat send or a handshake somebody else's // slot — the class of defect `req_id` was introduced for. if (msg.type === 'transfer_state') { const lease = this._leases.get(msg.tr); if (lease) lease._apply(msg); else if (msg.state === 'granted') { // A grant for a transfer this page has forgotten (a reload, a cancel // that raced the grant). Handing it back at once matters: otherwise the // node holds it until the 30 s acceptance deadline, and everyone behind // it waits for nothing. this._send({ type: 'transfer_close', v: '0.1', tr: msg.tr, reason: 'cancelled' }); } return; } if (msg.type === 'index_progress') { if (this._onIndexProgress) { // `kind`, `root_pos`, `queued` and the file counts are absent from a // node older than the indexing dock; index-dock-model.js reads them // missing as idle defaults. this._onIndexProgress({ scanning: Boolean(msg.scanning), scanned_bytes: msg.scanned_bytes || 0, total_bytes: msg.total_bytes || 0, files_done: msg.files_done || 0, files_total: msg.files_total || 0, kind: msg.kind || '', root_pos: Number.isInteger(msg.root_pos) ? msg.root_pos : -1, queued: Number.isInteger(msg.queued) ? msg.queued : 0, }); } return; } // Same reasoning as index_progress: nobody awaits this one either, it // is purely informational (group-settings.js does not currently act on // it), so it must not be left to fall through to the oldest pending // request. if (msg.type === 'set_scan_settings_ack') { return; } // Both index messages carry their payload sealed under a GEK-derived // subkey (MNP 1.0), so they cannot be acted on from here — _dispatch is // synchronous and opening one is not. `index_delta` is the incremental // form: additions/deletions/updates, never the whole index, and it only // ever arrives after the full index this browser already has (the node's // first push to a newly connected peer is always index_sync, see // daemon.py _broadcast_index_change), so there is always a base to apply // it to. if (msg.type === 'index_sync' || msg.type === 'index_delta') { this._queueIndexMessage(msg); return; } if (msg.type === 'file_chunk') { const key = `chunk:${msg.file_id}:${msg.chunk_index}`; for (const [, handler] of this._pending) { // A node from before the reply carried a file_id: fall back to the // index, which is still better than the oldest pending request. const match = msg.file_id ? handler._key === key : handler._key && handler._key.endsWith(`:${msg.chunk_index}`); if (match) { handler.resolve(msg); return; } } // Nobody asked for it any more — a cancelled download, most likely. It // must not be handed to whatever request happens to be waiting. console.warn('[MeshBay] file_chunk for nobody', msg.file_id, msg.chunk_index); return; } // "Server busy, retry shortly" and friends arrive as a bare error while a // stream is being set up, with no request waiting for them. They used to // fall through to the oldest pending handler — usually nobody — so the // player sat on "buffering" with the answer already in hand. if (msg.type === 'error' && this._onStreamError) { this._onStreamError(msg); return; } if (msg.type === 'pong') { const key = `ping:${msg.token}`; for (const [, handler] of this._pending) { if (handler._key === key) { handler.resolve(msg); return; } } // A pong for a probe that already timed out. It must not fall through to // the oldest pending request. return; } if (msg.type === 'media_meta_resp') { const key = `media_meta:${msg.file_id}`; for (const [, handler] of this._pending) { if (handler._key === key) { handler.resolve(msg); return; } } // Nobody asked for this file any more (tile scrolled out and a fresh // request superseded it, most likely) — must not fall through to the // oldest pending request, which would hand a different tile's promise // a TMDB result for a file it never asked about. return; } // Same reasoning as media_meta_resp: keyed by url, and "nobody's waiting" // must not fall through. if (msg.type === 'link_preview_resp') { const key = `link_preview:${msg.url}`; for (const [, handler] of this._pending) { if (handler._key === key) { handler.resolve(msg); return; } } return; } // Same reasoning as media_meta_resp: keyed, not arrival-order, and // "nobody's waiting any more" must not fall through either. if (msg.type === 'music_meta_resp') { const key = `music_meta:${msg.file_id}`; for (const [, handler] of this._pending) { if (handler._key === key) { handler.resolve(msg); return; } } return; } // Same reasoning as music_meta_resp: keyed, not arrival-order — the // player can have a transcode of the current track and a prefetch of // the next one in flight together. if (msg.type === 'audio_transcode_resp') { const key = `audio_transcode:${msg.file_id}`; for (const [, handler] of this._pending) { if (handler._key === key) { handler.resolve(msg); return; } } return; } // Keyed on file *and* track — see the `subtitle_req` key above. The node // echoes `track` back for exactly this: without it a reply could only be // matched to the film, and the two tracks of one film are precisely the // pair that can be in flight together. if (msg.type === 'subtitle_resp') { const key = `subtitle:${msg.file_id}:${msg.track}`; for (const [, handler] of this._pending) { if (handler._key === key) { handler.resolve(msg); return; } } return; } // Same reasoning as media_meta_resp: keyed, not arrival-order, and // "nobody's waiting any more" must not fall through either. if (msg.type === 'season_meta_resp') { const key = `season_meta:${msg.tmdb_id}:${msg.season}`; for (const [, handler] of this._pending) { if (handler._key === key) { handler.resolve(msg); return; } } return; } if (msg.type === 'tmdb_search_resp') { const key = `tmdb_search:${msg.media_type}:${msg.query}`; for (const [, handler] of this._pending) { if (handler._key === key) { handler.resolve(msg); return; } } return; } // chat_hist_resp answers a `chat_hist` request, but under a different // type string — unlike index_sync, which is asked for and answered under // the same name, so the generic fallback below happens to work for it by // accident. Without this check, whenever a chat_hist_resp arrives while // something else this browser asked for (fetchIndex, even the handshake // itself) is still the oldest pending entry, it gets handed to that // instead: the request chat_hist_resp actually belongs to then hangs // until _sendAndWait's own 30s timeout, and whatever it stole from // resolves with the wrong shape entirely — reproduced live as a // consistent ~30s hang immediately after a successful handshake, for one // specific group and not others connected the same way, which is exactly // what depending on response arrival order rather than on request type // predicts: it fires only when the two responses happen to reorder. if (msg.type === 'chat_hist_resp') { for (const [, handler] of this._pending) { if (handler._reqType === 'chat_hist') { handler.resolve(msg); return; } } console.warn('[MeshBay] chat_hist_resp with no matching chat_hist pending'); return; } // device_hello_ack ends in `_ack` but is not an admin op, so the admin // branch looks it up under `admin:device_hello` and finds nothing. A 2.0 // node stamps `req_id` and this is never reached; it is the per-type key // for a node that does not, alongside chat_hist_resp above. if (msg.type === 'device_hello_ack') { for (const [, handler] of this._pending) { if (handler._reqType === 'device_hello') { handler.resolve(msg); return; } } console.warn('[MeshBay] device_hello_ack with no matching request'); return; } // Same shape as chat_hist_resp above, and found the same way — by driving // the panel rather than by reading this file. Before `req_id` existed, // `chat_keys_resp` fell to the arrival-order guess and was handed to // whatever was oldest in `_pending`; `chat_send_probe.py` caught it on its // first run, with the Videos tab's unanswered `media_meta_req` swallowing // the chat keys and the send then waiting out its own 30s timeout with the // composer disabled. `req_id` is what closes that class now, and this is // the per-type key for a node that does not stamp one. if (msg.type === 'chat_keys_resp') { for (const [, handler] of this._pending) { if (handler._reqType === 'chat_keys_req') { handler.resolve(msg); return; } } console.warn('[MeshBay] chat_keys_resp with no matching request'); return; } // A bare `ack` answers three requests: sending a chat message, and storing // or withdrawing a keypair bundle. The bundle acks name themselves in // `detail`; the chat one carries nothing at all, so it was left to the // arrival-order guess below — and that guess is wrong whenever anything // else this browser asked for is still waiting. The ack went to *that* // request, and the chat send waited out its own 30s timeout instead. // // What that looked like, and what this was found from: typing a message // froze the Chat tab. The composer is disabled while a send is in flight, // so it stopped accepting clicks and keys; the message never appeared; // and it was there all along on the next visit to the tab, because the // node had stored it and answered — into somebody else's promise. One // unanswered request is enough, and an unanswered request is ordinary // rather than exceptional: the node refuses an unknown file_id with a // bare `error`, which names no request either and so reaches none, and a // Videos tab that asked about a file the index no longer has leaves a // `media_meta_req` sitting in `_pending` for the full 30s. if (msg.type === 'ack') { const named = msg.detail === 'keypair_bundle_stored' ? 'keypair_bundle_store' : msg.detail === 'keypair_bundle_deleted' ? 'keypair_bundle_delete' : null; // Without a `detail` it is a chat ack — but a node that names neither // is answering whichever of the three this browser has outstanding, so // the reply is placed rather than dropped. const wanted = named ? [named] : ['chat_msg', 'keypair_bundle_store', 'keypair_bundle_delete']; for (const want of wanted) { for (const [, handler] of this._pending) { if (handler._reqType === want) { handler.resolve(msg); return; } } } console.warn('[MeshBay] ack (detail=', msg.detail, ') with nothing waiting'); return; } // Everything above is routed by something in the message. What is left // used to be matched by arrival order — a guess, and a wrong guess hands // one request's answer to another, which then waits out its own 30s // timeout for a reply that already came and went. That is how the Chat // composer, disabled while a send is in flight, could stay disabled for // thirty seconds on a message the node had already stored. // // A node that stamps its replies (`req_id`, handled at the top) has taken // every one of its answers out of this path, so anything arriving here is // unsolicited and the guess can only ever be wrong. Dropping it loses // nothing and stops the theft. if (this._correlates) { console.warn('[MeshBay] unsolicited', msg.type, '— dropped (pending:', this._pending.size, ')'); return; } // Only a node too old to stamp anything reaches here, where arrival order // is still the only thing there is. Kept deliberately, and no wider than // it was: the alternative for such a node is that half the protocol // (device_list_result, join_result, the handshake's own replies) reaches // nobody at all. const oldest = this._pending.entries().next(); if (!oldest.done) { const [, handler] = oldest.value; if (msg.type !== handler._reqType + '_resp' && handler._reqType !== 'index_sync') { console.warn('[MeshBay] unrouted', msg.type, '-> oldest pending', handler._reqType, '(pending:', this._pending.size, ')'); } handler.resolve(msg); } else { console.warn('[MeshBay] unrouted', msg.type, 'with nothing waiting'); } } } /** * Why a code from an invitation link must not go to this node, or null. * * `link_other_node` is the caller's cue to try the next node the hub listed, * as for `not_hosted`: the link names one node, and this is not it. An older * node that cannot prove its key early is refused rather than trusted — it * cannot have issued a link code anyway. */ function _linkJoinRefusal(joinNodePk, joinCode, nodePk, nodePkProved) { if (!joinNodePk || !joinCode) return null; if (nodePk !== joinNodePk) { const err = new Error('This invitation was issued by another machine hosting this group.'); err.reason = 'link_other_node'; return err; } if (!nodePkProved) { const err = new Error('This node is too old to accept invitation links.'); err.reason = 'link_node_unproved'; return err; } return null; } /** * Whether `handshake_challenge` proves the key it announces (MNP 3.4). * * True when it carries a signature that verifies over this connection, false * when it carries none — an older node, which proves its key only at the ack. * A signature that does not verify is a peer lying about which node it is, and * throws: that is a refusal, not a node that merely cannot say. */ async function _challengeProvesNodeKey(reply, groupId, nonceClient, offerSdp, answerSdp) { if (!reply.sig) return false; const C = window.MeshBayCrypto; let ok = false; try { ok = Boolean(reply.node_pk) && await C.verifyNodeSignature( reply.node_pk, reply.sig, C.challengeTranscript(groupId, nonceClient, C.b64decode(reply.nonce), C.webrtcBinding(_extractDtlsFingerprint(offerSdp), _extractDtlsFingerprint(answerSdp)))); } catch { ok = false; } if (!ok) throw new Error('Node challenge signature invalid — refusing connection'); return true; } function _extractDtlsFingerprint(sdp) { const match = sdp.match(/a=fingerprint:sha-256 ([0-9A-Fa-f:]+)/); if (!match) return new Uint8Array(0); const hex = match[1].replace(/:/g, ''); const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); return bytes; } // ── Parts ─────────────────────────────────────────────────────────────────── // // MeshBayTransport's methods are written across several classic scripts, loaded // after this one in the shell's order (api/webapp.py, and the desktop client's // scripts/index.html): each declares its share in a class of its own, verbatim, // and hands it here to be copied onto the prototype. A name defined twice is a // mistake in the split, not an override, and fails loudly at load. function extendTransport(part) { for (const name of Object.getOwnPropertyNames(part.prototype)) { if (name === 'constructor') continue; if (Object.prototype.hasOwnProperty.call(MeshBayTransport.prototype, name)) { throw new Error(`MeshBayTransport.${name} is defined twice`); } Object.defineProperty(MeshBayTransport.prototype, name, Object.getOwnPropertyDescriptor(part.prototype, name)); } } // Export window.MeshBayTransport = MeshBayTransport;