/** * 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(); */ 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; } // 48 KB is what fits comfortably in one SCTP message across stacks; the window is // what makes the rate independent of the round trip. 32 × 48 KB = 1.5 MB in // flight, which saturates any path up to roughly 100 Mb/s at 100 ms. const UPLOAD_CHUNK_SIZE = 48 * 1024; const UPLOAD_WINDOW = 32; const UPLOAD_BUFFER_HIGH = 1024 * 1024; // 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; 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 below) — 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 audio_root's slot, then // audio_root's own 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', ]); /** 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 === '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', 'video_root', 'audio_root', 'photo_roots', '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', 'member_unpin', 'gek_rotate', 'group_attach', 'group_detach', 'invite_create', ]); // ── 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, 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 = '2.0'; const MNP_V_MIN = '1.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.', }; 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; }); } /** No slots on this node: behave as though one was granted at once. */ _skip() { this.state = 'granted'; this._granted(); } _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); if (!this.transport.supportsTransferSlots) return; 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; this._onReconnected = null; this._onNeedToken = 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 onUploadPolicy(fn) { this._onUploadPolicy = fn; } set onRootsChanged(fn) { this._onRootsChanged = fn; } /** The MNP version the connected node declared, or '' before a handshake. */ get nodeVersion() { return this._nodeVersion || ''; } /** * Whether this node hands out transfer slots. * * Read from the handshake ack rather than from the MNP version: the caps * shipped before the version bump that will make leases compulsory, so for * now a node either answers with `transfer_limits` or it predates all of * this. A node that does not is asked for nothing and enforces nothing — * every download behaves exactly as it did. */ get supportsTransferSlots() { return this._transferLimits !== null; } /** This member's own caps in this group, or null when the node said nothing. */ get transferLimits() { return this._transferLimits; } /** * Whether the node speaks the per-root and per-app operations MNP 1.1 added: * `root_update`/`root_eject`/`root_plug`, `app_directories`, * `chat_directory`, `chat_link_preview`. * * An older node has no equivalent for the root ones at all, and answers the * app ones through their three predecessors (`video_root`, `audio_root`, * `photo_roots`). The caller chooses which; what it must not do is send a * 1.1 message and wait, because an unknown type is logged and dropped. */ get supportsAppOps() { const m = /^(\d+)\.(\d+)$/.exec(this._nodeVersion || ''); if (!m) return false; return (Number(m[1]) > 1) || (Number(m[1]) === 1 && Number(m[2]) >= 1); } /** * Whether the node opens a sealed upload (MNP 2.0). * * A 1.x node reads `filename` and `data` off the message itself, finds * neither — they are inside the seal — and answers "Missing filename or * data", an error about the wrong thing that names no upload_id and so fails * every upload in flight. Asked before sending rather than discovered after, * for the same reason `supportsAppOps` is. */ get supportsSealedUpload() { const m = /^(\d+)\.(\d+)$/.exec(this._nodeVersion || ''); return !!m && Number(m[1]) >= 2; } 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 onChatEpoch(fn) { this._onChatEpoch = fn; } set onTmdbConfig(fn) { this._onTmdbConfig = fn; } set onTmdbEnabled(fn) { this._onTmdbEnabled = fn; } set onVideoRoot(fn) { this._onVideoRoot = fn; } set onAudioRoot(fn) { this._onAudioRoot = fn; } set onPhotoRoots(fn) { this._onPhotoRoots = 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; } // Fired once an automatic reconnect (see _reconnectLoop) lands a fresh // handshake, so a consumer with something mid-flight on the old channel — // today only the video player — can pick back up rather than sit dead. set onReconnected(fn) { this._onReconnected = 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; } async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username, userId, joinCode, recoveryKey) { // 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, }; 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; 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', {}); resolve(); }; }); 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 }); }; // 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 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: [], }), }); if (!resp.ok) { const detail = await resp.json().catch(() => ({})); throw new Error(`Signaling failed: ${resp.status} ${detail.detail || ''}`); } 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, not just checked. Several controls exist only on a node new // enough to have them, and the alternative to asking is offering a // button whose message an older node logs as unknown and never answers // — a 30-second wait ending in a timeout, with nothing on screen to say // the node simply cannot do this. 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; it is // unverified at this point and checked against the ack below. this._nonceNode = window.MeshBayCrypto.b64decode(reply.nonce); this.nodePk = reply.node_pk || null; // 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/auth-confirm.md §4.5): 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. 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; // Tell the node which of this account's devices is on this connection. // Deliberately after the ack, and gated on the node's own version rather // than sent hopefully: a node that does not know the message answers // nothing at all, which would leave a `device_hello` sitting in // `_pending` for the full 30s — and the arrival-order fallback hands an // unrouted reply to the *oldest* pending request, which right after a // handshake is exactly this one. That is the routed-by-luck bug the chat // ack comment above was written for; not repeating it. this._nodeMnp = String(ack.v || ''); // 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; // Not gated on a version any more: a node that reached this point speaks // MNP 2.0, where identifying the device is what makes chat possible at // all. `check_version` refused anything older before we got here. 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; } /** * "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, and a node older than MNP 1.2 does not know * the message. Neither is an error — the node simply keeps the weaker * attribution it had before, which is what every client did until now. */ async _announceDevice() { if (!this._sessionKeys || !this._sessionKeys.skEdB64) return; if (!this._nonceNode || !this.nodePk || !this._userId) return; 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, }); this.devicePk = (resp && resp.type === 'device_hello_ack') ? pkEdB64 : ''; return this.devicePk; } /** * 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; try { await this.connect(args.nodeId, token, args.groupId, args.gekRaw, this._sessionKeys, args.bundleKey, args.username, args.userId, args.joinCode); } 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(); if (this._onReconnected) { try { this._onReconnected(); } catch (e) { console.error('[MeshBay] onReconnected handler 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. } } } /** * Pair this browser with the node using a one-time code (M3, and the same * substitution as H3). * * The node has no way to know which key belongs to its operator unless someone * tells it locally — asking the hub would let the hub name itself node * administrator. The code comes from `meshbay-node operator pair`, over SSH, and * the hub never sees it. */ async pairOperator(userId, code) { if (!this._connected) throw new Error('Not connected to the node'); if (!userId) throw new Error('Missing user id'); if (!this._sessionKeys || !this._sessionKeys.skEdB64 || !this._sessionKeys.skXB64) { throw new Error('Identity keys unavailable in this browser — sign in again'); } if (!this._nonceNode || !this.nodePk) { throw new Error('Handshake incomplete — reconnect and retry'); } const C = window.MeshBayCrypto; // Both public keys are derived from OUR OWN secret keys, never read back from // the hub: signing a public key the directory handed us would reintroduce the // substitution this whole mechanism exists to close. const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64); const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64); const ts = Math.floor(Date.now() / 1000); // group_id is empty: operator authority is node-wide, not per group. const transcript = C.joinTranscript( this.nodePk, '', userId, pkEdB64, pkXB64, this._nonceNode, ts); const sig = await window.MeshBayKeys.signBytes(this._sessionKeys.skEdB64, transcript); const resp = await this._sendAndWait({ type: 'join_request', v: '0.1', group_id: '', pk_ed25519: pkEdB64, pk_x25519: pkXB64, code: code || '', ts, sig, }); if (resp.type === 'error') throw new Error(resp.detail || 'Pairing refused'); if (resp.type !== 'join_result' || !resp.ok) { const reason = resp.reason || 'unknown'; const err = new Error(JOIN_REFUSALS[reason] || `Pairing refused: ${reason}`); err.reason = reason; throw err; } this.memberRole = 'operator'; return resp; } /** * 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); if (!this.supportsTransferSlots) { lease._skip(); return lease; } this._leases.set(tr, lease); lease._request(); return lease; } /** Re-ask for every live lease. Called after a reconnect. */ _reopenTransfers() { if (!this.supportsTransferSlots) return; 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; } /** * TMDB metadata for one file (Videos app, docs/mediacenter.md §5.4). * Keyed by the entry's own `id` (its content hash) — never a path: a * path names the *folder* a file is in (indexer.py's `_virtual_dir`), so * two files sharing a folder (any multi-episode season) would resolve to * whichever entry the node's index happened to return first (found live * via the Music app's identical bug, 2026-08-25 — see webrtc_server.py's * `_do_media_meta_request`). * `confidence: 0` (no tmdb_id, no fields) means no confident match — * the caller falls back to a thumbnail-only card (§4.1), not an error. */ async fetchMediaMeta(fileId) { const msg = await this._sendAndWait({ type: 'media_meta_req', v: '0.6', file_id: fileId }); if (msg.type === 'error') throw new Error(msg.detail); return msg; } /** * Unfurl a URL pasted in chat. The node fetches it (the browser cannot — * CSP and CORS — and would leak every reader's IP), parses an OpenGraph * card, and caches any image in its thumb store; `image_thumb_hash` then * rides the normal file_req path like a poster. `ok: false` means "no * preview" (blocked, unreachable, not HTML) — the caller just shows the * bare link. Keyed by url: a message with several links fires one each. */ async fetchLinkPreview(url) { const msg = await this._sendAndWait({ type: 'link_preview_req', v: '0.6', url }); if (msg.type === 'error') throw new Error(msg.detail); return msg; } /** * One season's own overview/air_date/poster (docs/mediacenter.md §5.4's * per-season view) — a show's own tmdb_meta is one static field that does * not necessarily describe every season alike, found live: a 3-season * show whose overview read as season-3-specific for every season. * Keyed like media_meta_req: a season-tab bar can fire a request per tab * before the previous one lands, and matching by arrival order would hand * one season's data to a different season's tab whenever two responses * reordered. */ async fetchSeasonMeta(tmdbId, season) { const msg = await this._sendAndWait({ type: 'season_meta_req', v: '0.6', tmdb_id: tmdbId, season, }); if (msg.type === 'error') throw new Error(msg.detail); return msg; } /** * Raw TMDB search candidates for an operator correcting a wrong automatic * match — unlike fetchMediaMeta, this never collapses to one best guess: * a human picks from several, so several is the point. Read-only, not an * admin op: it looks nothing up in this node's own state and changes * nothing, so it needs no signature (mirrors why media_meta_req isn't * signed either). */ async searchTmdb(mediaType, query) { const msg = await this._sendAndWait({ type: 'tmdb_search_req', v: '0.6', media_type: mediaType, query, }); if (msg.type === 'error') throw new Error(msg.detail); return msg; } /** * Correct a wrong automatic TMDB match. Signed like setVideoRoot/ * setTmdbConfig: it replaces what every member sees for a show/movie, * node-wide (media_cache is shared, not per-viewer) — an unsigned * override would let any member vandalize another show's metadata. * Applies to every file sharing the representative one's display_title, * not just the file the operator happened to be looking at (webrtc_ * server.py's _admin_exec_tmdb_override). Keyed by `fileId`, not a path * — same reasoning as fetchMediaMeta above. */ async overrideTmdbMatch(fileId, tmdbId, mediaType, signFn) { const msg = await this._sendAndWait({ type: 'tmdb_override', v: '0.7', file_id: fileId, tmdb_id: tmdbId, media_type: mediaType, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { const subject = `file_id=${fileId},tmdb_id=${tmdbId},media_type=${mediaType}`; return this._authorizeAdminOp(msg, 'tmdb_override', subject, signFn); } return msg; } /** * Drop one file's cached TMDB match so it re-resolves with the node's * current matcher (§10.1/V13) — the one-click alternative to the full * search-and-pick flow. Signed for the same reason as overrideTmdbMatch. */ async rematchTmdbMatch(fileId, signFn) { const msg = await this._sendAndWait({ type: 'tmdb_rematch', v: '0.7', file_id: fileId, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp(msg, 'tmdb_rematch', `file_id=${fileId}`, signFn); } return msg; } /** * Set/clear a custom TMDB API token, and/or set the language TMDB is * queried in (e.g. "fr-FR") — one for the whole node, since both are one * operator's shared credential/cache, not a per-group concern (see * setTmdbEnabled below for the per-group on/off switch). Signed like * setAppsEnabled/updateRoot — an unsigned change would let any * member alter outbound third-party network traffic the operator never * agreed to (docs/mediacenter.md §5.5, §8). `token: ''` explicitly clears * a previously-set custom token; omit it (undefined/null), like * `language`, to leave whatever is stored unchanged. */ async setTmdbConfig(token, language, signFn) { const msg = await this._sendAndWait({ type: 'tmdb_config', v: '0.7', token: token === undefined ? null : token, language: language === undefined ? null : language, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { // Must match the node's subject byte-for-byte (webrtc_server.py // _do_tmdb_config) — the token itself is never part of the subject // (it would end up in the audit log in plaintext), only whether one // was supplied. The language is not a secret, so it appears as-is. const subject = `custom_token=${token ? 'yes' : 'no'},language=${language || 'default'}`; return this._authorizeAdminOp(msg, 'tmdb_config', subject, signFn); } return msg; } /** * Whether TMDB lookups run for this group at all — per-group (2026-08-24, * used to be node-wide): a real media-library group and a test/demo group * on the same node need not share the decision to spend TMDB quota and * make outbound requests. Signed like setVideoRoot — it decides whether * this group's members' Videos tab ever makes outbound TMDB traffic. */ async setTmdbEnabled(enabled, signFn) { const msg = await this._sendAndWait({ type: 'tmdb_enabled', v: '0.7', enabled: Boolean(enabled), }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { // Must match the node's subject byte-for-byte (webrtc_server.py // _do_tmdb_enabled): Python's f"{bool}" is "True"/"False", not JS's // lowercase. const subject = enabled ? 'True' : 'False'; return this._authorizeAdminOp(msg, 'tmdb_enabled', subject, signFn); } return msg; } /** * Which folder (possibly a subfolder of a shared root) the Videos app * treats as its entry point for this group. `path: ''` means the whole * group index. Signed like setAppsEnabled — it decides what every * member's Videos tab shows. */ async setVideoRoot(path, signFn) { const clean = (path || '').replace(/^\/+|\/+$/g, ''); const msg = await this._sendAndWait({ type: 'video_root', v: '0.5', path: clean }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp(msg, 'video_root', clean, signFn); } return msg; } /** * Same shape as setVideoRoot above — the Music app's own entry point. */ async setAudioRoot(path, signFn) { const clean = (path || '').replace(/^\/+|\/+$/g, ''); console.log('[MeshBay] setAudioRoot: sending request, path=', JSON.stringify(clean)); const msg = await this._sendAndWait({ type: 'audio_root', v: '0.10', path: clean }); console.log('[MeshBay] setAudioRoot: first reply =', msg); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp(msg, 'audio_root', clean, signFn); } return msg; } /** * Which folder(s) the Photos app treats as its entry points for this * group (docs/photos.md §2.1). Unlike setVideoRoot/setAudioRoot, `roots` * is a whole set, replaced in one signed op — same shape as * setAppsEnabled. The client normalizes the same way the node does * (webrtc_server.py's `_do_photo_roots`: trim slashes, drop empties, * dedupe, sort) so the subject built here matches byte-for-byte what the * node signs the challenge against. */ async setPhotoRoots(roots, signFn) { const clean = [...new Set( (roots || []).map((r) => (r || '').replace(/^\/+|\/+$/g, '')).filter(Boolean), )].sort(); const msg = await this._sendAndWait({ type: 'photo_roots', v: '0.11', roots: clean }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp(msg, 'photo_roots', clean.join(','), signFn); } return msg; } /** * Point an application at folder(s) inside the group's shared directories. * * One method for every app, keyed by the app's registry name — the same * generic op the node grew for the same reason (docs/refactor-groups.md * §1.6). `setVideoRoot`, `setAudioRoot` and `setPhotoRoots` are still here * and still work; nothing new should call them. * * The subject names the app as well as the paths, because an operator shown * "Media/Films" alone cannot tell which application is about to be pointed * at it, and two apps' challenges would otherwise be indistinguishable. * Cleaned and sorted the same way the node does, so both sides build the * same bytes to sign. */ /** * The same instruction a node too old for `app_directories` understands. * * Videos, Music and Photos each had their own message before this, and they * still work — so an operator on an un-upgraded node keeps the ability they * had, rather than being handed a control that silently times out. Chat has * no predecessor, which is why its settings are hidden rather than routed. */ async setAppDirectoriesLegacy(appKey, directories, signFn) { const clean = [...new Set( (directories || []).map((d) => (d || '').replace(/^\/+|\/+$/g, '')).filter(Boolean), )].sort(); if (appKey === 'photo') return this.setPhotoRoots(clean, signFn); // One folder was all these two could carry. Sending several would store // the first and silently drop the rest, so it is refused instead. if (clean.length > 1) { throw new Error( 'This node is older than this page and can hold one folder per app. ' + 'Update it, or choose a single folder.'); } const one = clean[0] || ''; if (appKey === 'video') return this.setVideoRoot(one, signFn); if (appKey === 'music') return this.setAudioRoot(one, signFn); throw new Error( 'This node is older than this page and cannot store this app\'s ' + 'folders. Its operator has to update it.'); } async setAppDirectories(appKey, directories, signFn) { const clean = [...new Set( (directories || []).map((d) => (d || '').replace(/^\/+|\/+$/g, '')).filter(Boolean), )].sort(); const msg = await this._sendAndWait({ type: 'app_directories', v: '1.1', app: appKey, directories: clean, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp( msg, 'app_directories', `${appKey}:${clean.join(',')}`, signFn); } return msg; } /** * Where chat attachments are written. * * Its own message rather than `setAppDirectories('chat', ...)`: this one is * a destination, and the node refuses a read-only root for it. A caller * reaching for the generic form would get a refusal it has no reason to * expect, so the difference is in the name. */ async setChatDirectory(path, signFn) { const clean = (path || '').replace(/^\/+|\/+$/g, ''); const msg = await this._sendAndWait({ type: 'chat_directory', v: '1.1', path: clean, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp(msg, 'chat_directory', clean, signFn); } return msg; } /** Whether the node unfurls links members post in this group's chat. */ async setChatLinkPreview(enabled, signFn) { const msg = await this._sendAndWait({ type: 'chat_link_preview', v: '1.1', enabled: Boolean(enabled), }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp( msg, 'chat_link_preview', enabled ? 'on' : 'off', signFn); } return msg; } /** * Open a new chat epoch by hand. Operator only, and signed. * * Not a switch — there is nothing to turn on. The removals that matter open * an epoch by themselves; this is the operator saying "move the key anyway", * the same instruction as `rotateGek` and signed for the same reason. */ async rotateChatEpoch(signFn) { // This connection's own group, not a parameter. Every settings pane takes // the same props by design (`test_app_settings_plugin.py`), so reaching for // a `groupId` here would make the loop that renders them conditional — and // the transport already knows which group it is connected to. const groupId = this._groupId || ''; const msg = await this._sendAndWait({ type: 'chat_epoch', v: '2.0', group_id: groupId, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp(msg, 'chat_epoch', groupId, signFn); } return msg; } /** * MusicBrainz metadata for one track (Music app, docs/musicbay.md §4.3) * — same shape as fetchMediaMeta, minus a season/episode concept: * album-level (release), resolved from the track's own artist/album * fields already in the index. Keyed by the track's own `id` (content * hash), not a path — a path names the *folder* a track is in, and an * album is one folder with many tracks in it; three unrelated albums * shared one folder's track's cover before this fix (found live, * 2026-08-25). `confidence: 0` means no confident match (or MusicBrainz * off for this group, or nothing configured) — the caller falls back to * the embedded/no cover it already had, not an error. */ async fetchMusicMeta(fileId) { const msg = await this._sendAndWait({ type: 'music_meta_req', v: '0.9', file_id: fileId }); if (msg.type === 'error') throw new Error(msg.detail); return msg; } /** * Server-side transcode of a Music-app file the browser's own