From c8af746c846b5dbc792f7e4f0d806647d513cc5c Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 20 Aug 2026 08:56:23 +0200 Subject: feat(node): full Node admin panel — CLI parity, hot-reload, group lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node admin panel (NodePage) now covers every CLI operation over MNP: group attach/detach, roster, member unpin, GEK rotate, denylist, reload. Daemon hot-loads new groups and tears down removed ones on config reload instead of requiring a full restart. Group attach/detach via MNP or local API triggers an automatic reload so the group is live immediately. Fixed GroupPage hang on first visit to a newly created group: the JWT issued at login didn't include the new group, the node rejected with not_a_member, and the token-refresh path returned without re-triggering the connect effect (Boolean(token) didn't change). Now bumps retryKey after a successful refresh so the effect re-runs with the fresh token. NodePage marks groups hosted by the node but absent from the hub with a "not on hub" badge so stale groups are visible and easy to remove. Co-Authored-By: Claude Opus 4.6 --- .../src/meshbay_hub/static/transport.js | 120 ++++++++++++++++++++- 1 file changed, 117 insertions(+), 3 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index a0509a0..17886f4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -217,6 +217,9 @@ class MeshBayTransport { 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. @@ -229,6 +232,7 @@ class MeshBayTransport { group_id: groupId || '', nonce: window.MeshBayCrypto.b64encode(this._nonceClient), }); + console.log('[MeshBay] Handshake reply:', reply.type); if (reply.type === 'handshake_challenge') { if (!window.MeshBayCrypto) { @@ -380,11 +384,9 @@ class MeshBayTransport { // 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}`)); - // `not_a_member` usually means our token predates being added to the group; - // the caller refreshes it and tries again rather than showing that to someone - // who was invited thirty seconds ago. rejected.reason = reply.code || ''; throw rejected; } @@ -622,6 +624,112 @@ class MeshBayTransport { return msg; } + // ── Node management (D5) ─────────────────────────────────────────────── + + async fetchNodeStatus() { + const msg = await this._sendAndWait({ type: 'node_status', v: '0.1' }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + async addRoot(groupId, path, { name, kind, upload } = {}, signFn) { + const msg = await this._sendAndWait({ + type: 'root_add', v: '0.1', + group_id: groupId, path, + name: name || '', kind: kind || 'generic', upload: !!upload, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'root_add', path, signFn); + } + return msg; + } + + async removeRoot(groupId, rootName, signFn) { + const msg = await this._sendAndWait({ + type: 'root_remove', v: '0.1', + group_id: groupId, root_name: rootName, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'root_remove', rootName, signFn); + } + return msg; + } + + async unpinMember(userId, signFn) { + const msg = await this._sendAndWait({ + type: 'member_unpin', v: '0.1', user_id: userId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'member_unpin', userId, signFn); + } + return msg; + } + + async rotateGek(groupId, signFn) { + const msg = await this._sendAndWait({ + type: 'gek_rotate', v: '0.1', group_id: groupId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'gek_rotate', groupId, signFn); + } + return msg; + } + + async fetchRoster(groupId) { + const msg = await this._sendAndWait({ + type: 'roster_read', v: '0.1', group_id: groupId || '', + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + async fetchDenylist() { + const msg = await this._sendAndWait({ type: 'denylist_read', v: '0.1' }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + async clearDenylist(subject) { + const msg = await this._sendAndWait({ + type: 'denylist_clear', v: '0.1', subject: subject || '', + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + async attachGroup(name, sharedDir, uploadDir, signFn) { + const msg = await this._sendAndWait({ + type: 'group_attach', v: '0.1', + name, shared_dir: sharedDir, upload_dir: uploadDir || '', + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'group_attach', name, signFn); + } + return msg; + } + + async detachGroup(name, signFn) { + const msg = await this._sendAndWait({ + type: 'group_detach', v: '0.1', name, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'group_detach', name, signFn); + } + return msg; + } + + async reloadConfig() { + const msg = await this._sendAndWait({ type: 'node_reload', v: '0.1' }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + /** * Ask for a video stream, and say how much we can take. * @@ -1003,6 +1111,8 @@ class MeshBayTransport { const id = this._seqId++; const timeout = setTimeout(() => { this._pending.delete(id); + console.error('[MeshBay] Response timeout for', obj.type, + 'after', timeoutMs, 'ms, channel=', this._channel?.readyState); reject(new Error('Response timeout')); }, timeoutMs); this._pending.set(id, { @@ -1040,6 +1150,10 @@ class MeshBayTransport { _onMessage(data) { const incoming = new Uint8Array(data); + this._msgCount = (this._msgCount || 0) + 1; + if (this._msgCount <= 3) { + console.log('[MeshBay] recv', incoming.length, 'bytes, msg #' + this._msgCount); + } const combined = new Uint8Array(this._recvBuf.length + incoming.length); combined.set(this._recvBuf); combined.set(incoming, this._recvBuf.length); -- cgit v1.2.3