aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-10 22:12:59 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-10 22:12:59 +0200
commit60c4570e72e36c2a9720593c8baec74ee2ab52d6 (patch)
treee833e222a6a95e64e5b24a0813882636044198c2 /packages/meshbay-hub/src/meshbay_hub/static
parent1a53eb4cc404ec94658fde0ae04cfe2ccf1810dc (diff)
downloadmeshbay-60c4570e72e36c2a9720593c8baec74ee2ab52d6.tar.gz
feat: Phase 9.1–9.5 — WebRTC DataChannel transport for browser P2P
Browser clients can now connect P2P to nodes behind residential NAT via WebRTC DataChannel with ICE/STUN. Validated on SFR Port-Restricted Cone NAT + 4G CGNAT across three scenarios (WiFi LAN, 4G IPv6, 4G IPv4 STUN). No TURN relay needed. Hub serves only as signaling relay (<1 KB). New files: - webrtc_server.py: aiortc-based WebRTC transport (node side) - signaling.py: SDP/ICE relay endpoint (hub side) - transport.js: browser WebRTC client with msgpack framing - webrtc-test.html: spike test page for browser→NAT→node validation - test_webrtc_transport.py: 4 tests (handshake, file transfer, auth, guard) - meshbay-draft-v4.md: architecture spec updated for web client Modified: - hub_client.py: WebRTC offer handling via hub WebSocket - revocation.py: node_id from WS auth + webrtc_answer routing - pyproject.toml: aiortc>=1.9 dependency 123 tests passing (117 existing + 6 new). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js409
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html258
2 files changed, 667 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
new file mode 100644
index 0000000..9112734
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -0,0 +1,409 @@
+/**
+ * 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();
+ */
+
+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;
+ }
+
+ get connected() { return this._connected; }
+
+ set onChat(fn) { this._onChat = fn; }
+
+ async connect(nodeId, jwtToken, groupId) {
+ this._pc = new RTCPeerConnection({
+ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
+ });
+
+ this._channel = this._pc.createDataChannel('mnp', { ordered: true });
+ this._channel.binaryType = 'arraybuffer';
+
+ const channelReady = new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error('DataChannel open timeout')), 30000);
+ this._channel.onopen = () => {
+ clearTimeout(timeout);
+ this._connected = true;
+ resolve();
+ };
+ this._channel.onerror = (e) => {
+ clearTimeout(timeout);
+ reject(new Error('DataChannel error: ' + e.message));
+ };
+ });
+
+ this._channel.onmessage = (event) => this._onMessage(event.data);
+ this._channel.onclose = () => { this._connected = false; };
+
+ const offer = await this._pc.createOffer();
+ await this._pc.setLocalDescription(offer);
+
+ await new Promise((resolve) => {
+ if (this._pc.iceGatheringState === 'complete') return resolve();
+ this._pc.onicegatheringstatechange = () => {
+ if (this._pc.iceGatheringState === 'complete') resolve();
+ };
+ });
+
+ const resp = await fetch(`${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();
+ await this._pc.setRemoteDescription({ type: 'answer', sdp: answer.sdp });
+
+ await channelReady;
+
+ const ack = await this._sendAndWait({
+ type: 'handshake',
+ v: '0.1',
+ token: jwtToken,
+ group_id: groupId || '',
+ });
+
+ if (ack.type !== 'handshake_ack') {
+ throw new Error('MNP handshake rejected: ' + (ack.detail || JSON.stringify(ack)));
+ }
+
+ return ack;
+ }
+
+ async fetchIndex() {
+ const msg = await this._sendAndWait({ type: 'index_sync', v: '0.1' });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return _b64decode(msg.index_b64);
+ }
+
+ 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;
+ }
+
+ async fetchStreamSegment(fileId, segmentIndex, segmentDuration) {
+ const msg = await this._sendAndWait({
+ type: 'stream_seg',
+ v: '0.1',
+ file_id: fileId,
+ segment_index: segmentIndex,
+ segment_duration: segmentDuration || 4,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return _b64decode(msg.data_b64);
+ }
+
+ async sendChat(payload, iteration, threadId) {
+ const msg = await this._sendAndWait({
+ type: 'chat_msg',
+ v: '0.1',
+ payload: payload,
+ iteration: iteration || 0,
+ thread_id: threadId || null,
+ });
+ return msg;
+ }
+
+ close() {
+ if (this._channel) this._channel.close();
+ if (this._pc) this._pc.close();
+ this._connected = false;
+ for (const [, p] of this._pending) p.reject(new Error('Transport closed'));
+ this._pending.clear();
+ }
+
+ // ── Internal ──────────────────────────────────────────────────────────────
+
+ _sendAndWait(obj) {
+ return new Promise((resolve, reject) => {
+ const id = this._seqId++;
+ const timeout = setTimeout(() => {
+ this._pending.delete(id);
+ reject(new Error('Response timeout'));
+ }, 30000);
+ this._pending.set(id, {
+ resolve: (msg) => { clearTimeout(timeout); this._pending.delete(id); resolve(msg); },
+ reject: (err) => { clearTimeout(timeout); this._pending.delete(id); reject(err); },
+ });
+ this._send(obj);
+ });
+ }
+
+ _send(obj) {
+ const encoded = msgpack_encode(obj);
+ const header = new Uint8Array(4);
+ new DataView(header.buffer).setUint32(0, encoded.byteLength, false);
+ const frame = new Uint8Array(4 + encoded.byteLength);
+ frame.set(header);
+ frame.set(encoded, 4);
+ this._channel.send(frame);
+ }
+
+ _onMessage(data) {
+ const incoming = new Uint8Array(data);
+ const combined = new Uint8Array(this._recvBuf.length + incoming.length);
+ combined.set(this._recvBuf);
+ combined.set(incoming, this._recvBuf.length);
+ this._recvBuf = combined;
+
+ while (this._recvBuf.length >= 4) {
+ const len = new DataView(this._recvBuf.buffer, this._recvBuf.byteOffset).getUint32(0, false);
+ if (this._recvBuf.length < 4 + len) break;
+ const msgBytes = this._recvBuf.slice(4, 4 + len);
+ this._recvBuf = this._recvBuf.slice(4 + len);
+
+ const msg = msgpack_decode(msgBytes);
+ this._dispatch(msg);
+ }
+ }
+
+ _dispatch(msg) {
+ if (msg.type === 'chat_msg' && this._onChat) {
+ this._onChat(msg);
+ return;
+ }
+
+ const oldest = this._pending.entries().next();
+ if (!oldest.done) {
+ const [, handler] = oldest.value;
+ handler.resolve(msg);
+ }
+ }
+}
+
+// ── Minimal msgpack encode/decode ────────────────────────────────────────────
+// Covers the subset used by MNP: maps, strings, integers, binary, arrays, null.
+
+function msgpack_encode(obj) {
+ const parts = [];
+ _encodeValue(obj, parts);
+ const total = parts.reduce((s, p) => s + p.length, 0);
+ const result = new Uint8Array(total);
+ let off = 0;
+ for (const p of parts) { result.set(p, off); off += p.length; }
+ return result;
+}
+
+function _encodeValue(val, parts) {
+ if (val === null || val === undefined) {
+ parts.push(new Uint8Array([0xc0]));
+ } else if (typeof val === 'boolean') {
+ parts.push(new Uint8Array([val ? 0xc3 : 0xc2]));
+ } else if (typeof val === 'number') {
+ if (Number.isInteger(val)) {
+ if (val >= 0 && val <= 127) {
+ parts.push(new Uint8Array([val]));
+ } else if (val >= 0 && val <= 0xff) {
+ parts.push(new Uint8Array([0xcc, val]));
+ } else if (val >= 0 && val <= 0xffff) {
+ const b = new Uint8Array(3); b[0] = 0xcd;
+ new DataView(b.buffer).setUint16(1, val, false);
+ parts.push(b);
+ } else if (val >= 0 && val <= 0xffffffff) {
+ const b = new Uint8Array(5); b[0] = 0xce;
+ new DataView(b.buffer).setUint32(1, val, false);
+ parts.push(b);
+ } else if (val >= -32 && val < 0) {
+ parts.push(new Uint8Array([val & 0xff]));
+ } else if (val >= -128 && val < 0) {
+ const b = new Uint8Array(2); b[0] = 0xd0; b[1] = val & 0xff;
+ parts.push(b);
+ } else {
+ const b = new Uint8Array(5); b[0] = 0xd2;
+ new DataView(b.buffer).setInt32(1, val, false);
+ parts.push(b);
+ }
+ } else {
+ const b = new Uint8Array(9); b[0] = 0xcb;
+ new DataView(b.buffer).setFloat64(1, val, false);
+ parts.push(b);
+ }
+ } else if (typeof val === 'string') {
+ const encoded = new TextEncoder().encode(val);
+ if (encoded.length <= 31) {
+ parts.push(new Uint8Array([0xa0 | encoded.length]));
+ } else if (encoded.length <= 0xff) {
+ parts.push(new Uint8Array([0xd9, encoded.length]));
+ } else if (encoded.length <= 0xffff) {
+ const b = new Uint8Array(3); b[0] = 0xda;
+ new DataView(b.buffer).setUint16(1, encoded.length, false);
+ parts.push(b);
+ } else {
+ const b = new Uint8Array(5); b[0] = 0xdb;
+ new DataView(b.buffer).setUint32(1, encoded.length, false);
+ parts.push(b);
+ }
+ parts.push(encoded);
+ } else if (val instanceof Uint8Array) {
+ if (val.length <= 0xff) {
+ parts.push(new Uint8Array([0xc4, val.length]));
+ } else if (val.length <= 0xffff) {
+ const b = new Uint8Array(3); b[0] = 0xc5;
+ new DataView(b.buffer).setUint16(1, val.length, false);
+ parts.push(b);
+ } else {
+ const b = new Uint8Array(5); b[0] = 0xc6;
+ new DataView(b.buffer).setUint32(1, val.length, false);
+ parts.push(b);
+ }
+ parts.push(val);
+ } else if (Array.isArray(val)) {
+ if (val.length <= 15) {
+ parts.push(new Uint8Array([0x90 | val.length]));
+ } else if (val.length <= 0xffff) {
+ const b = new Uint8Array(3); b[0] = 0xdc;
+ new DataView(b.buffer).setUint16(1, val.length, false);
+ parts.push(b);
+ } else {
+ const b = new Uint8Array(5); b[0] = 0xdd;
+ new DataView(b.buffer).setUint32(1, val.length, false);
+ parts.push(b);
+ }
+ for (const item of val) _encodeValue(item, parts);
+ } else if (typeof val === 'object') {
+ const keys = Object.keys(val);
+ if (keys.length <= 15) {
+ parts.push(new Uint8Array([0x80 | keys.length]));
+ } else if (keys.length <= 0xffff) {
+ const b = new Uint8Array(3); b[0] = 0xde;
+ new DataView(b.buffer).setUint16(1, keys.length, false);
+ parts.push(b);
+ } else {
+ const b = new Uint8Array(5); b[0] = 0xdf;
+ new DataView(b.buffer).setUint32(1, keys.length, false);
+ parts.push(b);
+ }
+ for (const k of keys) {
+ _encodeValue(k, parts);
+ _encodeValue(val[k], parts);
+ }
+ }
+}
+
+function msgpack_decode(buf) {
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
+ const [val] = _decodeValue(buf, view, 0);
+ return val;
+}
+
+function _decodeValue(buf, view, offset) {
+ const byte = buf[offset];
+
+ if (byte <= 0x7f) return [byte, offset + 1];
+ if ((byte & 0xe0) === 0xe0) return [byte - 256, offset + 1];
+ if ((byte & 0xa0) === 0xa0) {
+ const len = byte & 0x1f;
+ return [new TextDecoder().decode(buf.slice(offset + 1, offset + 1 + len)), offset + 1 + len];
+ }
+ if ((byte & 0xf0) === 0x90) {
+ const len = byte & 0x0f;
+ return _decodeArray(buf, view, offset + 1, len);
+ }
+ if ((byte & 0xf0) === 0x80) {
+ const len = byte & 0x0f;
+ return _decodeMap(buf, view, offset + 1, len);
+ }
+
+ switch (byte) {
+ case 0xc0: return [null, offset + 1];
+ case 0xc2: return [false, offset + 1];
+ case 0xc3: return [true, offset + 1];
+ case 0xc4: { const len = buf[offset + 1]; return [buf.slice(offset + 2, offset + 2 + len), offset + 2 + len]; }
+ case 0xc5: { const len = view.getUint16(offset + 1, false); return [buf.slice(offset + 3, offset + 3 + len), offset + 3 + len]; }
+ case 0xc6: { const len = view.getUint32(offset + 1, false); return [buf.slice(offset + 5, offset + 5 + len), offset + 5 + len]; }
+ case 0xcc: return [buf[offset + 1], offset + 2];
+ case 0xcd: return [view.getUint16(offset + 1, false), offset + 3];
+ case 0xce: return [view.getUint32(offset + 1, false), offset + 5];
+ case 0xcb: return [view.getFloat64(offset + 1, false), offset + 9];
+ case 0xd0: return [view.getInt8(offset + 1), offset + 2];
+ case 0xd1: return [view.getInt16(offset + 1, false), offset + 3];
+ case 0xd2: return [view.getInt32(offset + 1, false), offset + 5];
+ case 0xd9: {
+ const len = buf[offset + 1];
+ return [new TextDecoder().decode(buf.slice(offset + 2, offset + 2 + len)), offset + 2 + len];
+ }
+ case 0xda: {
+ const len = view.getUint16(offset + 1, false);
+ return [new TextDecoder().decode(buf.slice(offset + 3, offset + 3 + len)), offset + 3 + len];
+ }
+ case 0xdb: {
+ const len = view.getUint32(offset + 1, false);
+ return [new TextDecoder().decode(buf.slice(offset + 5, offset + 5 + len)), offset + 5 + len];
+ }
+ case 0xdc: { const len = view.getUint16(offset + 1, false); return _decodeArray(buf, view, offset + 3, len); }
+ case 0xdd: { const len = view.getUint32(offset + 1, false); return _decodeArray(buf, view, offset + 5, len); }
+ case 0xde: { const len = view.getUint16(offset + 1, false); return _decodeMap(buf, view, offset + 3, len); }
+ case 0xdf: { const len = view.getUint32(offset + 1, false); return _decodeMap(buf, view, offset + 5, len); }
+ default: throw new Error(`Unknown msgpack type: 0x${byte.toString(16)}`);
+ }
+}
+
+function _decodeArray(buf, view, offset, count) {
+ const arr = [];
+ for (let i = 0; i < count; i++) {
+ const [val, newOff] = _decodeValue(buf, view, offset);
+ arr.push(val);
+ offset = newOff;
+ }
+ return [arr, offset];
+}
+
+function _decodeMap(buf, view, offset, count) {
+ const obj = {};
+ for (let i = 0; i < count; i++) {
+ const [key, off1] = _decodeValue(buf, view, offset);
+ const [val, off2] = _decodeValue(buf, view, off1);
+ obj[key] = val;
+ offset = off2;
+ }
+ return [obj, offset];
+}
+
+function _b64decode(b64) {
+ const binary = atob(b64);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return bytes;
+}
+
+// Export
+window.MeshBayTransport = MeshBayTransport;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html b/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html
new file mode 100644
index 0000000..0d5750b
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html
@@ -0,0 +1,258 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>MeshBay — WebRTC Spike Test</title>
+ <style>
+ *, *::before, *::after { box-sizing: border-box; }
+ body { font-family: system-ui, sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; }
+ .container { max-width: 800px; margin: 32px auto; padding: 0 16px; }
+ h1 { color: #38bdf8; font-size: 1.4em; }
+ h2 { color: #94a3b8; font-size: 1.1em; margin-top: 2em; }
+ .step { background: #1e293b; border: 1px solid #334155; border-radius: 8px;
+ padding: 16px; margin: 12px 0; }
+ .step.done { border-color: #22c55e; }
+ .step.fail { border-color: #ef4444; }
+ .step.active { border-color: #38bdf8; }
+ input { padding: 8px 12px; border: 1px solid #475569; border-radius: 6px;
+ background: #0f172a; color: #e2e8f0; font-size: 0.95em; margin: 4px; width: 240px; }
+ button { padding: 8px 20px; background: #0ea5e9; color: #fff; border: none;
+ border-radius: 6px; cursor: pointer; font-size: 0.95em; margin: 4px; }
+ button:hover { background: #0284c7; }
+ button:disabled { background: #475569; cursor: not-allowed; }
+ #log { background: #020617; border: 1px solid #1e293b; border-radius: 8px;
+ padding: 12px; font-family: monospace; font-size: 0.85em; line-height: 1.6;
+ max-height: 400px; overflow-y: auto; white-space: pre-wrap; }
+ .ok { color: #22c55e; }
+ .err { color: #ef4444; }
+ .info { color: #38bdf8; }
+ .warn { color: #f59e0b; }
+ .dim { color: #64748b; }
+ .badge { display: inline-block; background: #22c55e; color: #0f172a; padding: 2px 8px;
+ border-radius: 4px; font-size: 0.8em; font-weight: bold; margin-left: 8px; }
+ .badge.fail { background: #ef4444; color: #fff; }
+ </style>
+</head>
+<body>
+<div class="container">
+ <h1>MeshBay — WebRTC DataChannel Spike Test</h1>
+ <p class="dim">Phase 9.5 — E2E browser → NAT → node file transfer via WebRTC</p>
+
+ <div class="step" id="step-login">
+ <h2>1. Login to Hub</h2>
+ <input id="username" placeholder="Username" value="bob">
+ <input id="password" placeholder="Password" type="password" value="bob">
+ <button id="btn-login" onclick="doLogin()">Login</button>
+ <span id="login-status"></span>
+ </div>
+
+ <div class="step" id="step-connect">
+ <h2>2. Connect to Node via WebRTC</h2>
+ <input id="node-id" placeholder="Node ID">
+ <input id="group-id" placeholder="Group ID (optional)">
+ <button id="btn-connect" onclick="doConnect()" disabled>Connect</button>
+ <span id="connect-status"></span>
+ </div>
+
+ <div class="step" id="step-transfer">
+ <h2>3. File Transfer Test</h2>
+ <button id="btn-index" onclick="doFetchIndex()" disabled>Fetch Index</button>
+ <br>
+ <input id="file-id" placeholder="File ID (blake3 hex, from node log)">
+ <button id="btn-chunk" onclick="doFetchChunk()" disabled>Fetch Chunk</button>
+ <span id="transfer-status"></span>
+ </div>
+
+ <h2>Log</h2>
+ <div id="log"></div>
+</div>
+
+<script src="/transport.js"></script>
+<script>
+const HUB_URL = window.location.origin;
+const params = new URLSearchParams(window.location.search);
+let accessToken = null;
+let jwtToken = null;
+let transport = null;
+let fileIndex = null;
+
+// Pre-fill from URL params
+if (params.get('user')) document.getElementById('username').value = params.get('user');
+if (params.get('pass')) document.getElementById('password').value = params.get('pass');
+if (params.get('node')) document.getElementById('node-id').value = params.get('node');
+if (params.get('group')) document.getElementById('group-id').value = params.get('group');
+if (params.get('file')) document.getElementById('file-id').value = params.get('file');
+
+// Auto-run if all params provided
+if (params.get('auto')) {
+ setTimeout(async () => {
+ await doLogin();
+ if (accessToken) await doConnect();
+ if (transport && transport.connected) {
+ await doFetchIndex();
+ if (document.getElementById('file-id').value) await doFetchChunk();
+ }
+ }, 500);
+}
+
+function logMsg(cls, text) {
+ const el = document.getElementById('log');
+ const line = document.createElement('span');
+ line.className = cls;
+ line.textContent = text + '\n';
+ el.appendChild(line);
+ el.scrollTop = el.scrollHeight;
+}
+
+function setStep(id, state) {
+ const el = document.getElementById(id);
+ el.className = 'step ' + state;
+}
+
+async function doLogin() {
+ const user = document.getElementById('username').value;
+ const pass = document.getElementById('password').value;
+ logMsg('info', `Logging in as ${user}...`);
+ setStep('step-login', 'active');
+
+ try {
+ const resp = await fetch(`${HUB_URL}/v1/users/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username: user, password: pass }),
+ });
+
+ if (!resp.ok) {
+ const err = await resp.json();
+ throw new Error(err.detail || resp.statusText);
+ }
+
+ const data = await resp.json();
+ accessToken = data.access_token;
+ jwtToken = data.access_token;
+ logMsg('ok', `Login OK — token: ${accessToken.substring(0, 20)}...`);
+ setStep('step-login', 'done');
+ document.getElementById('login-status').innerHTML = '<span class="badge">OK</span>';
+ document.getElementById('btn-connect').disabled = false;
+ } catch (e) {
+ logMsg('err', `Login FAILED: ${e.message}`);
+ setStep('step-login', 'fail');
+ document.getElementById('login-status').innerHTML = '<span class="badge fail">FAIL</span>';
+ }
+}
+
+async function doConnect() {
+ const nodeId = document.getElementById('node-id').value;
+ const groupId = document.getElementById('group-id').value;
+ if (!nodeId) { logMsg('warn', 'Enter a node ID'); return; }
+
+ logMsg('info', `Connecting to node ${nodeId.substring(0, 8)}... via WebRTC`);
+ setStep('step-connect', 'active');
+
+ try {
+ transport = new MeshBayTransport(HUB_URL, accessToken);
+
+ logMsg('dim', ' Creating RTCPeerConnection...');
+ logMsg('dim', ' Creating DataChannel "mnp"...');
+ logMsg('dim', ' Gathering ICE candidates...');
+ logMsg('dim', ' Sending SDP offer to hub...');
+
+ const t0 = performance.now();
+ const ack = await transport.connect(nodeId, jwtToken, groupId);
+ const elapsed = (performance.now() - t0).toFixed(0);
+
+ logMsg('ok', `WebRTC connected in ${elapsed}ms`);
+ logMsg('ok', ` MNP handshake_ack — node_pk: ${ack.node_pk?.substring(0, 16)}...`);
+ logMsg('ok', ` DataChannel: open, ordered, reliable`);
+ setStep('step-connect', 'done');
+ document.getElementById('connect-status').innerHTML = '<span class="badge">P2P OK</span>';
+ document.getElementById('btn-index').disabled = false;
+ document.getElementById('btn-chunk').disabled = false;
+ } catch (e) {
+ logMsg('err', `Connection FAILED: ${e.message}`);
+ setStep('step-connect', 'fail');
+ document.getElementById('connect-status').innerHTML = '<span class="badge fail">FAIL</span>';
+ }
+}
+
+async function doFetchIndex() {
+ logMsg('info', 'Fetching Mesh Group Index via DataChannel...');
+ try {
+ const t0 = performance.now();
+ const indexBytes = await transport.fetchIndex();
+ const elapsed = (performance.now() - t0).toFixed(0);
+
+ logMsg('ok', `Index received: ${indexBytes.byteLength} bytes in ${elapsed}ms`);
+
+ try {
+ const envelope = msgpack_decode(indexBytes);
+ logMsg('dim', ` type: ${envelope.type}, encrypted: ${envelope.encrypted}, version: ${envelope.version}`);
+ logMsg('dim', ` group_id: ${envelope.group_id}`);
+
+ if (envelope.encrypted) {
+ logMsg('warn', ` Index is GEK-encrypted — browser decryption not implemented in spike`);
+ logMsg('dim', ` ct_b64 length: ${envelope.ct_b64?.length || 0} chars`);
+ logMsg('info', ` Spike workaround: enter a file_id manually or use Fetch First Chunk`);
+ // Store envelope so chunk test can proceed with manual file_id
+ fileIndex = { entries: [], envelope };
+ } else {
+ // Public group: decompress and parse
+ logMsg('dim', ` Public index — data_b64 length: ${envelope.data_b64?.length || 0}`);
+ fileIndex = { entries: [], envelope };
+ }
+ } catch (pe) {
+ logMsg('warn', ` Could not parse index envelope: ${pe.message}`);
+ }
+ } catch (e) {
+ logMsg('err', `Index fetch FAILED: ${e.message}`);
+ }
+}
+
+async function doFetchChunk() {
+ let fileId = document.getElementById('file-id').value.trim();
+
+ if (!fileId) {
+ logMsg('warn', 'Enter a file_id (blake3 hex hash from node indexer log)');
+ logMsg('dim', ' Look for "Initial scan complete" in the node terminal');
+ logMsg('dim', ' Or run: python -c "import blake3; print(blake3.blake3(open(\'QE/demo-v3/shared_media/sample.txt\',\'rb\').read()).hexdigest())"');
+ return;
+ }
+
+ logMsg('info', `Fetching chunk 0 of file ${fileId.substring(0, 16)}... via DataChannel...`);
+
+ try {
+ const t0 = performance.now();
+ const chunkMsg = await transport.fetchChunk(fileId, 0);
+ const elapsed = (performance.now() - t0).toFixed(0);
+
+ if (chunkMsg.type === 'error') {
+ logMsg('err', `Chunk fetch error: ${chunkMsg.detail}`);
+ return;
+ }
+
+ logMsg('ok', `Chunk received in ${elapsed}ms:`);
+ logMsg('ok', ` type: ${chunkMsg.type}`);
+ logMsg('ok', ` chunk_index: ${chunkMsg.chunk_index}`);
+ logMsg('ok', ` plaintext_size: ${chunkMsg.plaintext_size} bytes`);
+ logMsg('ok', ` ct_b64 length: ${chunkMsg.ct_b64?.length || 0} chars`);
+ logMsg('ok', ` nonce_b64: ${chunkMsg.nonce_b64?.substring(0, 16)}...`);
+ logMsg('ok', ` sig_b64: ${chunkMsg.sig_b64?.substring(0, 16)}...`);
+
+ logMsg('', '');
+ logMsg('ok', '=== SPIKE TEST PASSED ===');
+ logMsg('ok', 'Browser connected to node via WebRTC DataChannel.');
+ logMsg('ok', 'MNP handshake, index sync, and file chunk transfer all work.');
+ logMsg('ok', 'Data flowed P2P — hub was only used for signaling.');
+
+ setStep('step-transfer', 'done');
+ document.getElementById('transfer-status').innerHTML = '<span class="badge">E2E OK</span>';
+ } catch (e) {
+ logMsg('err', `Chunk fetch FAILED: ${e.message}`);
+ setStep('step-transfer', 'fail');
+ document.getElementById('transfer-status').innerHTML = '<span class="badge fail">FAIL</span>';
+ }
+}
+</script>
+</body>
+</html>