aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-25 11:28:31 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-25 12:07:49 +0200
commitb7bf11c077bdd165400cf8d80c5cd1ad4248d854 (patch)
treee80590cd7b798e21a4ac663fd1e653a66e5f3b34 /packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
parent6348698322e5bfcb80d0ef0dcd833ba4e6179640 (diff)
downloadmeshbay-b7bf11c077bdd165400cf8d80c5cd1ad4248d854.tar.gz
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js309
1 files changed, 309 insertions, 0 deletions
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' };
+ }
+ }
+});