From b7bf11c077bdd165400cf8d80c5cd1ad4248d854 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 25 Sep 2026 11:28:31 +0200 Subject: refactor(client): split transport.js into classic scripts transport.js keeps the core (connection, reconnect, leases, dispatch). Chat, media, admin, upload and device methods move, cut as text, into transport-*.js scripts that hand a class of their own to extendTransport, which copies each method onto MeshBayTransport.prototype; the codec, roster checks, node pins and the rewrap fan-out move as they were. Both shells load them after transport.js. Every prototype member, class property and top-level function has the same source text as before. Co-Authored-By: Claude Opus 5.5 --- .../src/meshbay_hub/static/transport-chat.js | 309 +++++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js new file mode 100644 index 0000000..d6d733f --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js @@ -0,0 +1,309 @@ +// The group chat: history, sending, link previews, and the epoch keys that +// seal it. +// +// Methods of MeshBayTransport, copied onto its prototype by extendTransport +// (transport.js, which the shell loads first). + +extendTransport(class { + /** + * 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; + } + + /** + * Where chat attachments are written. + * + * Its own message rather than `setAppDirectories('chat', ...)`: this one is + * a destination, and the node refuses a read-only root for it. A caller + * reaching for the generic form would get a refusal it has no reason to + * expect, so the difference is in the name. + */ + async setChatDirectory(path, signFn) { + const clean = (path || '').replace(/^\/+|\/+$/g, ''); + const msg = await this._sendAndWait({ + type: 'chat_directory', v: '1.1', path: clean, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'chat_directory', clean, signFn); + } + return msg; + } + + /** Whether the node unfurls links members post in this group's chat. */ + async setChatLinkPreview(enabled, signFn) { + const msg = await this._sendAndWait({ + type: 'chat_link_preview', v: '1.1', enabled: Boolean(enabled), + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp( + msg, 'chat_link_preview', enabled ? 'on' : 'off', signFn); + } + return msg; + } + + /** + * Open a new chat epoch by hand. Operator only, and signed. + * + * Not a switch — there is nothing to turn on. The removals that matter open + * an epoch by themselves; this is the operator saying "move the key anyway", + * the same instruction as `rotateGek` and signed for the same reason. + */ + async rotateChatEpoch(signFn) { + // This connection's own group, not a parameter. Every settings pane takes + // the same props by design (`test_app_settings_plugin.py`), so reaching for + // a `groupId` here would make the loop that renders them conditional — and + // the transport already knows which group it is connected to. + const groupId = this._groupId || ''; + const msg = await this._sendAndWait({ + type: 'chat_epoch', v: '2.0', group_id: groupId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'chat_epoch', groupId, signFn); + } + return msg; + } + + /** + * A page of chat history, newest first by default. + * + * `before` is a message id, not a timestamp: it pages backwards from the + * newest, which is the direction a conversation is read. Asking without it + * used to mean `since: 0`, which paged *forwards* from the very first message + * — so a busy group opened on its oldest page and never showed the recent + * exchange. + * + * Returns { messages, hasMore } — hasMore says whether anything older exists, + * so the "load older" control knows when to stop offering. + */ + async fetchChatHistory({ before = null, limit = 100 } = {}) { + const msg = await this._sendAndWait({ + type: 'chat_hist', + v: '0.2', + before: before, + limit: limit, + }); + if (msg.type === 'error') throw new Error(msg.detail); + const rows = msg.messages || []; + const messages = []; + for (const row of rows) messages.push(await this._openChatMessage(row)); + return { messages, hasMore: !!msg.has_more }; + } + + /** + * Turn one stored or relayed chat message into what the panel renders. + * + * **The one place that decides how a message is read.** Live messages and + * history arrive by different routes and used to be shaped at each of them; + * with a `format` column and more than one way to read a payload, two copies + * of that decision is two places to get it wrong, and the disagreement would + * show up only in history. + * + * `payload` is bytes on the wire now — the node stopped decoding it as UTF-8, + * which mangled anything that was not text. A message that cannot be read + * comes back marked rather than thrown away: a gap in a conversation the + * reader can see is honest, and silently dropping messages is not. + */ + async _openChatMessage(row) { + const base = { + id: row.id, + sender_id: row.sender_id, + sender_name: row.sender_name || '', + timestamp: row.timestamp, + thread_id: row.thread_id, + }; + const format = row.format || 0; + if (format === 0) { + return { ...base, payload: _asText(row.payload) }; + } + if (format !== 1) { + return { ...base, payload: '', unreadable: 'format' }; + } + return this._openSealedChat(base, row); + } + + /** + * Send one message, sealed under this group's current chat epoch key and + * signed with this device's key. + * + * There is no plaintext path. MNP 2.0 has no unencrypted chat and the node + * refuses one, so a fallback here could only ever produce a refusal the user + * cannot act on — and a client that quietly posted in clear into a group + * whose members believe their chat is encrypted is the downgrade the whole + * design is about not having. + * + * `sender_name` goes **inside** the envelope. On the wire it is a field any + * peer can set to anything, and the node caches it to render history — so + * display-name spoofing is free while chat is plaintext. Sealed and signed, + * it is as authenticated as the message it names. + * + * Refuses rather than falls back. A client that cannot seal must not quietly + * post in clear into a group whose members believe their chat is encrypted; + * the node refuses it too, and the two refusals agreeing is the point. + */ + async sendChat(text, iteration, threadId, senderName) { + const keys = await this.chatKeys(); + const epoch = this.chatEpoch || keys.current; + const epochKey = keys.byEpoch.get(epoch); + if (!epochKey) throw new Error('No chat key for this group — reconnect'); + if (!this.devicePk || !this._sessionKeys || !this._sessionKeys.skEdB64) { + throw new Error('This device is not identified to the node — reconnect'); + } + + const C = window.MeshBayCrypto; + const gid = this._groupId || ''; + const plaintext = msgpack_encode({ + text: String(text), + thread_id: threadId || null, + sender_name: senderName || '', + sent_at: Math.floor(Date.now() / 1000), + }); + const { nonce, ct } = await C.sealChat( + epochKey, gid, epoch, this.devicePk, plaintext); + const device = C.b64decode(this.devicePk); + const sig = C.b64decode(await window.MeshBayKeys.signBytes( + this._sessionKeys.skEdB64, + C.chatSigningTranscript(gid, epoch, device, nonce, ct))); + + const msg = await this._sendAndWait({ + type: 'chat_msg', + v: '2.0', + format: 1, + epoch, + ct, + nonce, + device, + sig, + thread_id: threadId || null, + // Deliberately absent: the display name is inside the envelope now. + sender_name: null, + }); + // Every other request in this file refuses an `error` reply; this one + // returned it as though the node had accepted the message. It never + // mattered while a refusal reached the wrong caller anyway — now that a + // reply finds the request that made it, a message the node rejected would + // otherwise appear in the conversation as sent. It rejects more of them + // than it used to: a stale epoch, an envelope the node dislikes, or a + // device claim that is not this connection's all come back as `error`. + if (msg.type === 'error') throw new Error(msg.detail || 'chat send refused'); + return msg; + } + + /** + * This group's chat epoch moved. + * + * An epoch opens when somebody is removed, and a client that kept sealing + * under the retired key would be writing messages the group can still read + * but that the removed member could read too. Dropping the cached keys is + * what makes the next send fetch the new one. + */ + _applyChatEpoch(msg) { + if (msg.epoch) this.chatEpoch = msg.epoch; + this._chatKeys = null; + this._chatKeysInFlight = null; + if (this._onChatEpoch) this._onChatEpoch(this.chatEpoch); + } + + /** + * Every chat epoch key for this group, fetched once per connection. + * + * Every epoch, not just the current one — that is what lets a device linked + * this morning read a conversation from last year, and a member who joined + * yesterday read the history the group already had. The node decides which + * epochs a member is entitled to; this asks for what it is given. + */ + async chatKeys() { + if (this._chatKeys) return this._chatKeys; + if (this._chatKeysInFlight) return this._chatKeysInFlight; + + this._chatKeysInFlight = (async () => { + const resp = await this._sendAndWait({ + type: 'chat_keys_req', v: '2.0', group_id: this._groupId || '', + }); + if (resp.type === 'error') throw new Error(resp.detail); + // Sealed under a group-derived subkey. A payload that does not open is + // not "no keys" — it is a peer we cannot talk to, and treating it as an + // empty set would present an encrypted group as one with no history. + const payload = msgpack_decode(await window.MeshBayCrypto.openGroup( + this._gekRaw, 'chat_keys', 'chat_keys_resp', this._groupId || '', resp)); + const byEpoch = new Map(); + for (const e of payload.epochs || []) byEpoch.set(e.epoch, e.key); + this._chatKeys = { byEpoch, current: payload.current || 0 }; + return this._chatKeys; + })(); + try { + return await this._chatKeysInFlight; + } finally { + this._chatKeysInFlight = null; + } + } + + /** + * Open one sealed message, or mark it unreadable and say why. + * + * Authorship is established **before** decryption: the signature is over the + * ciphertext, so a message that does not verify is never rendered as having + * been written by the account it claims — which is the whole point of signing + * rather than trusting the node's `sender_id`. + * + * An unreadable message is kept and marked, never dropped. A gap the reader + * can see is honest; a conversation quietly missing messages is not. + */ + async _openSealedChat(base, row) { + const C = window.MeshBayCrypto; + const gid = this._groupId || ''; + const epoch = row.epoch || 0; + const device = row.device; + const nonce = row.nonce; + const ct = row.ct; + if (!device || !nonce || !ct || !row.sig) { + return { ...base, payload: '', unreadable: 'envelope' }; + } + + if (!await C.verifyChatSignature(device, gid, epoch, nonce, ct, row.sig)) { + return { ...base, payload: '', unreadable: 'signature' }; + } + + let keys; + try { + keys = await this.chatKeys(); + } catch { + return { ...base, payload: '', unreadable: 'keys' }; + } + const epochKey = keys.byEpoch.get(epoch); + if (!epochKey) return { ...base, payload: '', unreadable: 'epoch' }; + + const deviceB64 = C.b64encode(device); + try { + const plain = msgpack_decode( + await C.openChat(epochKey, gid, epoch, deviceB64, nonce, ct)); + // The signature proves *a device* wrote this. Whether that device belongs + // to the account the node named is a separate question, and one this + // client answers for itself from the roster (Tier 2) rather than taking + // `sender_id` on trust. `changed` is the only value worth a notice. + const trust = await this.accountDeviceStatus(base.sender_id, deviceB64); + return { + ...base, + payload: String(plain.text || ''), + sender_name: plain.sender_name || base.sender_name, + thread_id: plain.thread_id ?? base.thread_id, + device: deviceB64, + verified: true, + trust, + }; + } catch { + return { ...base, payload: '', unreadable: 'decrypt' }; + } + } +}); -- cgit v1.2.3