/** * 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_config', 'tmdb_enabled', 'video_root', 'audio_root', 'photo_roots', 'musicbrainz_config', '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', ]); 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(); } 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 onMusicbrainzConfig(fn) { this._onMusicbrainzConfig = fn; } set onMusicbrainzEnabled(fn) { this._onMusicbrainzEnabled = fn; } set onIndexProgress(fn) { this._onIndexProgress = 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) { 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' }], }); 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; resolve(); }; }); this._channel.onmessage = (event) => this._onMessage(event.data); this._channel.onclose = (ev) => { console.warn('[MeshBay] DataChannel closed', this._channel?.readyState, ev); 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); if (channelReject) channelReject(new Error('DataChannel error')); }; this._pc.onconnectionstatechange = () => { console.log('[MeshBay] PC state:', this._pc.connectionState); }; this._pc.oniceconnectionstatechange = () => { console.log('[MeshBay] ICE state:', this._pc.iceConnectionState); }; 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; } /** * 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). * `path` is root+relpath, exactly what index_sync/index_delta already * gave this browser — never a raw filesystem path constructed here. * `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(path) { const msg = await this._sendAndWait({ type: 'media_meta_req', v: '0.5', path }); 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). */ async overrideTmdbMatch(path, tmdbId, mediaType, signFn) { const msg = await this._sendAndWait({ type: 'tmdb_override', v: '0.6', path, tmdb_id: tmdbId, media_type: mediaType, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { const subject = `path=${path},tmdb_id=${tmdbId},media_type=${mediaType}`; return this._authorizeAdminOp(msg, 'tmdb_override', subject, 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's path (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. `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(path) { const msg = await this._sendAndWait({ type: 'music_meta_req', v: '0.8', path }); if (msg.type === 'error') throw new Error(msg.detail); return msg; } /** * Server-side transcode of a Music-app file the browser's own