/** * 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. 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', 'member_upload', 'apps_enabled', 'set_scan_settings', 'member_revoke', 'root_add', 'root_remove', '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 */ } }, }; 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(); }); 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.', }; class MeshBayTransport { constructor(hubUrl, accessToken) { this._hubUrl = hubUrl; this._accessToken = accessToken; this._pc = null; this._channel = null; this._pending = new Map(); 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; // filename → the uploader waiting on it. Keyed rather than FIFO because // several uploads may be in flight at once and their acks interleave; the // node names the file in every one. this._uploaders = new Map(); // 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 onAppsEnabled(fn) { this._onAppsEnabled = 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 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; } async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username, userId, joinCode) { // 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 }; 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._username = username || null; this._userId = userId || null; this._newNodeBundle = null; this._joinError = null; this._pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }, { urls: 'stun:stun.cloudflare.com:3478' }, { urls: 'stun:stun.services.mozilla.com:3478' }, ], }); 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: '0.1', token: jwtToken, group_id: groupId || '', nonce: window.MeshBayCrypto.b64encode(this._nonceClient), }); console.log('[MeshBay] Handshake reply:', reply.type); if (reply.type === 'handshake_challenge') { 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', }); if (kpResp.type === 'keypair_bundle_resp' && kpResp.found) { const keys = await window.MeshBayKeys.decryptBundleWithKey( kpResp.bundle_enc, this._bundleKey); const pkXB64 = await _pkFromSk(keys.skX); this._sessionKeys = { skXB64: keys.skX, skEdB64: keys.skEd, pkXB64 }; } else { // This node has never seen us. Generate the identity we will use here // and nowhere else; it is stored on this node once the join succeeds, // which is what lets another browser become the same person here. const id = await window.MeshBayKeys.generateNodeIdentity(this._bundleKey); this._sessionKeys = { skEdB64: id.skEdB64, skXB64: id.skXB64, pkXB64: id.pkXB64, }; this._newNodeBundle = id.bundleEnc; 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 identity keys in this browser and none recoverable from the node: // the keypair bundle is created where you register and only reaches a // node after a first successful connection, so a brand-new member opening // a second browser has nothing to sign or unwrap with. Say that, rather // than blaming the GEK — a code prompt here would be useless, since a // code proves who you are and we have no key to bind to. const err = new Error( 'This browser does not hold your keys. Open the group once from the ' + 'browser where you registered — after that this one can recover them.'); 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; 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( 'MNP handshake rejected: ' + (reply.detail || `unexpected ${reply.type}`)); rejected.reason = reply.code || ''; throw rejected; } /** * 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)'); 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; } async fetchIndex() { const msg = await this._sendAndWait({ type: 'index_sync', v: '0.1' }); if (msg.type === 'error') throw new Error(msg.detail); return msg; } async fetchChunk(fileId, chunkIndex) { const msg = await this._sendAndWait({ type: 'file_req', v: '0.1', file_id: fileId, chunk_index: chunkIndex, }); 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/setMemberUpload — 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; } /** * 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