diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-23 18:05:14 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-23 18:05:14 +0200 |
| commit | 35a7764db3f58a93c32206cb3ce74bb2f03967e7 (patch) | |
| tree | d2acb2a07ee6dc2f4241fcc785c2860d57263892 /packages/meshbay-hub/src/meshbay_hub/static/transport.js | |
| parent | 998f9c69308ee88fac36cfb77dfb6d07c6fa926a (diff) | |
| download | meshbay-35a7764db3f58a93c32206cb3ce74bb2f03967e7.tar.gz | |
feat(hub): open, create and join invitation links in the interface
#/invite takes the link out of the address on load and keeps it in the
tab through registration and sign-in; joining is one click, only the
ticket goes to the hub, and the code goes only to the node the link
names once it has signed its challenge. Members tab gains "Invite by
link" (shared e-mail box, pending list, cancel both halves); home page
takes a pasted link. Browser probe drives the real app, signed out and in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 70 |
1 files changed, 66 insertions, 4 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index c43422f..f6adaac 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -123,7 +123,7 @@ const ADMIN_OP_TYPES = new Set([ 'root_add', 'root_remove', 'root_update', 'root_eject', 'root_plug', 'app_directories', 'chat_directory', 'chat_link_preview', 'chat_epoch', 'search_listed', 'member_unpin', 'gek_rotate', 'group_attach', - 'group_detach', 'invite_create', + 'group_detach', 'invite_create', 'invite_link_create', 'invite_cancel', ]); // ── Diagnostic trace (opt-in, off by default) ─────────────────────────────── @@ -614,13 +614,14 @@ class MeshBayTransport { } async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username, - userId, joinCode, recoveryKey) { + userId, joinCode, recoveryKey, joinNodePk) { // Remembered for _reconnectLoop, which calls connect() again with these // same values (plus a freshly-fetched token and the identity connect() // itself settles on below) after the WebRTC connection is declared // "failed" — see the pc.onconnectionstatechange handler further down. this._connectArgs = { nodeId, groupId, gekRaw, bundleKey, username, userId, joinCode, recoveryKey, + joinNodePk, }; this._lastToken = jwtToken; // The constructor sets this once from whatever token the caller had at @@ -984,7 +985,16 @@ class MeshBayTransport { // 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) { + // A code from an invitation link goes to the node the link names and to + // no other, and only once that node has proved the key in its challenge + // (docs/MESHBAY_DESIGN.md §3.4). Otherwise nothing is sent at all — not + // even a join without the code, which this node would answer by asking + // for one. + const linkRefusal = _linkJoinRefusal(joinNodePk, joinCode, this.nodePk, + this.nodePkProved); + if (linkRefusal) { + this._joinError = linkRefusal; + } else if (!gekRaw && this._sessionKeys && userId) { try { gekRaw = await this.joinGroup(userId, groupId, joinCode); } catch (e) { @@ -1240,7 +1250,8 @@ class MeshBayTransport { try { ack = await this.connect(args.nodeId, token, args.groupId, args.gekRaw, this._sessionKeys, args.bundleKey, args.username, - args.userId, args.joinCode); + args.userId, args.joinCode, undefined, + args.joinNodePk); } finally { this._inReconnectAttempt = false; } @@ -2627,6 +2638,34 @@ class MeshBayTransport { } /** + * A code bound to no account, for an invitation link (MNP 3.4). Signed like + * any invitation, and the subject the operator signs is the outcome: a link + * into this group, `link:<group>`, and nothing else. + */ + async createLinkInvite(groupId, signFn) { + const msg = await this._sendAndWait({ + type: 'invite_link_create', v: '0.1', group_id: groupId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'invite_link_create', `link:${groupId}`, signFn); + } + return msg; + } + + /** Take back an unredeemed invitation link, by the handle it was issued with. */ + async cancelLinkInvite(inviteId, signFn) { + const msg = await this._sendAndWait({ + type: 'invite_cancel', v: '0.1', invite_id: inviteId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'invite_cancel', inviteId, signFn); + } + return msg; + } + + /** * Ask the node to recognise us and hand over the group key. * * Sent when we hold no GEK for a group. `code` is needed only the first time @@ -3982,6 +4021,29 @@ function _hex(bytes) { } /** + * Why a code from an invitation link must not go to this node, or null. + * + * `link_other_node` is the caller's cue to try the next node the hub listed, + * as for `not_hosted`: the link names one node, and this is not it. An older + * node that cannot prove its key early is refused rather than trusted — it + * cannot have issued a link code anyway. + */ +function _linkJoinRefusal(joinNodePk, joinCode, nodePk, nodePkProved) { + if (!joinNodePk || !joinCode) return null; + if (nodePk !== joinNodePk) { + const err = new Error('This invitation was issued by another machine hosting this group.'); + err.reason = 'link_other_node'; + return err; + } + if (!nodePkProved) { + const err = new Error('This node is too old to accept invitation links.'); + err.reason = 'link_node_unproved'; + return err; + } + return null; +} + +/** * Whether `handshake_challenge` proves the key it announces (MNP 3.4). * * True when it carries a signature that verifies over this connection, false |