diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-15 12:57:51 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-15 12:57:51 +0200 |
| commit | a86bced1640ac1a695b95ac68b95e3fc581e687d (patch) | |
| tree | fe8ea8af945e139a2308eb19ad572e10a41b05e4 /packages | |
| parent | 988a0eecd522431980174910db28e45a63ff2710 (diff) | |
| download | meshbay-a86bced1640ac1a695b95ac68b95e3fc581e687d.tar.gz | |
refactor(chat): no thread_id — chat has no threads
Nothing ever set it (the page and the CLI sent null) and nothing read it
(the node's get_thread had no caller). It is no longer sent by the page or
the CLI, in clear or in the sealed payload, nor relayed or served by the
node; get_thread and its index are gone, and the protocol and design docs
no longer name it.
The node still accepts a chat_msg carrying one, from the desktop apps and
CLIs already installed, and drops it. The column stays, empty, in chat.db:
a node downgraded to an older version names it in every query.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
11 files changed, 64 insertions, 66 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/chat.py b/packages/meshbay-cli/src/meshbay_cli/client/chat.py index 587303a..d525abf 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/chat.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/chat.py @@ -186,7 +186,7 @@ async def post(session: GroupSession, keys: ChatKeys, text: str, sender_name: st ident = session.identity device_b64 = ident.pk_ed sealed = seal(key, session.group_id, epoch, device_b64, unb64(device_b64), ident.sk_ed, - {"text": text, "thread_id": None, "sender_name": sender_name, + {"text": text, "sender_name": sender_name, "sent_at": int(time.time())}) if len(sealed["ct"]) > MAX_CIPHERTEXT: raise CliError(USAGE, "This message is too large for chat; send it as a file " @@ -195,5 +195,5 @@ async def post(session: GroupSession, keys: ChatKeys, text: str, sender_name: st "format": FORMAT_SEALED, "epoch": epoch, "ct": sealed["ct"], "nonce": sealed["nonce"], "device": unb64(device_b64), "sig": sealed["sig"], - "thread_id": None, "sender_name": None}, "ack") + "sender_name": None}, "ack") diff --git a/packages/meshbay-cli/tests/fake_node.py b/packages/meshbay-cli/tests/fake_node.py index 552bd39..c618ee4 100644 --- a/packages/meshbay-cli/tests/fake_node.py +++ b/packages/meshbay-cli/tests/fake_node.py @@ -140,7 +140,7 @@ class FakeNode: def _store(self, device: bytes, epoch: int, sealed: dict, sender_id: str) -> None: self.chat.append({"id": len(self.chat) + 1, "sender_id": sender_id, "sender_name": "", "timestamp": 1_700_000_000 + len(self.chat), - "thread_id": None, "format": 1, "epoch": epoch, + "format": 1, "epoch": epoch, "device": device, "nonce": sealed["nonce"], "sig": sealed["sig"], "payload": "", "ct": sealed["ct"]}) diff --git a/packages/meshbay-common/tests/test_js_python_parity.py b/packages/meshbay-common/tests/test_js_python_parity.py index bb52742..8f7f7b9 100644 --- a/packages/meshbay-common/tests/test_js_python_parity.py +++ b/packages/meshbay-common/tests/test_js_python_parity.py @@ -497,7 +497,7 @@ const toHex = (u8) => def _chat_payload(idx: int) -> dict: """A distinct message per vector, so a crossed result cannot pass.""" - return {"text": f"message {idx} — café", "thread_id": None, + return {"text": f"message {idx} — café", "sender_name": f"member-{idx}", "sent_at": 1_700_000_000 + idx} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js index 1d61c43..34e86eb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js @@ -472,7 +472,7 @@ function ChatPanel({ transportRef, username, userId, entries, gekRef, setSending(true); setInput(''); try { - await transport.sendChat(text, 0, null, username); + await transport.sendChat(text, 0, username); setMessages(prev => [...prev, { id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`, own: true, @@ -480,7 +480,6 @@ function ChatPanel({ transportRef, username, userId, entries, gekRef, sender_name: username, payload: text, timestamp: Date.now() / 1000, - thread_id: null, }]); jumpToBottom(); if (onActivity) onActivity(); @@ -515,11 +514,11 @@ function ChatPanel({ transportRef, username, userId, entries, gekRef, const structured = JSON.stringify({ text: '', attachment: { filename: storedAs, size: file.size, type: ftype }, }); - await transport.sendChat(structured, 0, null, username); + await transport.sendChat(structured, 0, username); setMessages(prev => [...prev, { id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`, own: true, sender_id: userId || username, sender_name: username, - payload: structured, timestamp: Date.now() / 1000, thread_id: null, + payload: structured, timestamp: Date.now() / 1000, }]); jumpToBottom(); } catch (err) { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js index 9ce07bc..3efbb22 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js @@ -138,7 +138,6 @@ extendTransport(class { 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) { @@ -169,7 +168,7 @@ extendTransport(class { * 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) { + async sendChat(text, iteration, senderName) { const keys = await this.chatKeys(); const epoch = this.chatEpoch || keys.current; const epochKey = keys.byEpoch.get(epoch); @@ -182,7 +181,6 @@ extendTransport(class { 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), }); @@ -204,7 +202,6 @@ extendTransport(class { nonce, device, sig, - thread_id: threadId || null, // Deliberately absent: the display name is inside the envelope now. sender_name: null, }); @@ -315,7 +312,6 @@ extendTransport(class { ...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, diff --git a/packages/meshbay-hub/tests/harness/chat_scroll_probe.py b/packages/meshbay-hub/tests/harness/chat_scroll_probe.py index 17ec554..5ef6c76 100644 --- a/packages/meshbay-hub/tests/harness/chat_scroll_probe.py +++ b/packages/meshbay-hub/tests/harness/chat_scroll_probe.py @@ -65,7 +65,7 @@ for (let i = 0; i < N; i++) { // grows the list the way the real thing does. payload: (i % 5 === 0 ? 'see https://example.invalid/p/' + i + ' ' : '') + 'message number ' + i + ', long enough to give the bubble a height', - timestamp: now - (N - i) * 60, thread_id: null, + timestamp: now - (N - i) * 60, }); } diff --git a/packages/meshbay-node/src/meshbay_node/chat/store.py b/packages/meshbay-node/src/meshbay_node/chat/store.py index 9e07aff..7610b33 100644 --- a/packages/meshbay-node/src/meshbay_node/chat/store.py +++ b/packages/meshbay-node/src/meshbay_node/chat/store.py @@ -54,8 +54,11 @@ CREATE TABLE IF NOT EXISTS messages ( sender_name TEXT DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(timestamp); -CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id); """ +# `thread_id` is written and read by nothing: chat has no threads. The column +# stays, empty, because a node downgraded to an older version names it in every +# query, and its rows must keep opening. Its index goes; an older node makes it +# again on its own. # Additive, every one with a default, so an existing chat.db opens unchanged and # a node that is downgraded still reads its own rows. `CREATE TABLE IF NOT @@ -88,7 +91,6 @@ class StoredMessage: iteration: int payload: bytes timestamp: float - thread_id: str | None sender_name: str = "" format: int = FORMAT_PLAIN epoch: int = 0 @@ -97,16 +99,16 @@ class StoredMessage: sig: bytes | None = None -_COLUMNS = ("id, sender_id, iteration, payload, timestamp, thread_id, " +_COLUMNS = ("id, sender_id, iteration, payload, timestamp, " "sender_name, format, epoch, device, nonce, sig") def _row(r) -> StoredMessage: return StoredMessage( id=r[0], sender_id=r[1], iteration=r[2], payload=r[3], - timestamp=r[4], thread_id=r[5], sender_name=r[6] or "", - format=r[7] or FORMAT_PLAIN, epoch=r[8] or 0, - device=r[9], nonce=r[10], sig=r[11], + timestamp=r[4], sender_name=r[5] or "", + format=r[6] or FORMAT_PLAIN, epoch=r[7] or 0, + device=r[8], nonce=r[9], sig=r[10], ) @@ -132,6 +134,7 @@ class ChatStore: # nothing to show for it. pass await self._db.execute(_REPLAY_INDEX) + await self._db.execute("DROP INDEX IF EXISTS idx_messages_thread") await self._db.commit() async def close(self) -> None: @@ -151,7 +154,6 @@ class ChatStore: sender_id: str, iteration: int, payload: bytes, - thread_id: str | None = None, sender_name: str = "", *, format: int = FORMAT_PLAIN, @@ -171,9 +173,9 @@ class ChatStore: try: cursor = await self._db.execute( "INSERT INTO messages (sender_id, iteration, payload, timestamp, " - " thread_id, sender_name, format, epoch, device, nonce, sig) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - (sender_id, iteration, payload, ts, thread_id, sender_name, + " sender_name, format, epoch, device, nonce, sig) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (sender_id, iteration, payload, ts, sender_name, format, epoch, device, nonce, sig), ) except aiosqlite.IntegrityError as e: @@ -234,16 +236,6 @@ class ChatStore: "SELECT 1 FROM messages WHERE id < ? LIMIT 1", (message_id,)) return await cursor.fetchone() is not None - async def get_thread(self, thread_id: str, limit: int = 100) -> list[StoredMessage]: - """Get messages in a thread.""" - cursor = await self._db.execute( - f"SELECT {_COLUMNS} " - "FROM messages WHERE thread_id = ? ORDER BY timestamp ASC LIMIT ?", - (thread_id, limit), - ) - rows = await cursor.fetchall() - return [_row(r) for r in rows] - @property def db_path(self) -> Path: """Where this store lives — the re-encryption command backs it up.""" diff --git a/packages/meshbay-node/src/meshbay_node/ops/chat.py b/packages/meshbay-node/src/meshbay_node/ops/chat.py index 9fc7b94..7caf85c 100644 --- a/packages/meshbay-node/src/meshbay_node/ops/chat.py +++ b/packages/meshbay-node/src/meshbay_node/ops/chat.py @@ -214,7 +214,6 @@ async def encrypt_chat_history(state: dict, group_id: str) -> dict: if isinstance(row.payload, bytes) else str(row.payload)) env = seal(key, group_id, epoch, device_b64, device_raw, sk_node, { "text": text, - "thread_id": row.thread_id, "sender_name": row.sender_name, "sent_at": int(row.timestamp), # The node sealed this after the fact; it did not witness it being diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py index ed2c43a..5bc9dbb 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py @@ -75,7 +75,7 @@ _LINK_PREVIEW_RATE_NODE = 60 # C5a; this is the same question asked of the path nobody had asked it of. # # 64 KB of ciphertext is about sixty thousand characters. The sealed payload is -# the text, a thread id, a display name and a timestamp: an attachment is a file +# the text, a display name and a timestamp: an attachment is a file # on a root and travels as a reference (§4.5), so nothing legitimate comes close. MAX_CHAT_CIPHERTEXT = 64 * 1024 # Per account per group, not per connection: a second tab does not make a person @@ -330,18 +330,14 @@ class ChatMixin: # anyone on the node. gctx = self._group_ctx() chat_store = gctx.get("chat_store") - # The two fields that travel in clear beside the ciphertext (the sealed - # envelope carries its own). Stored and relayed to every member, so they - # are what they claim to be and no larger: a name as long as a username, - # a thread id as long as a message id. Anything else is dropped. + # The one field that travels in clear beside the ciphertext (the sealed + # envelope carries its own). Stored and relayed to every member, so it is + # what it claims to be and no larger: a name as long as a username. + # Anything else is dropped. A `thread_id` from an older client is + # ignored: chat has no threads. sender_name = msg.get("sender_name", "") if not isinstance(sender_name, str) or len(sender_name) > 64: sender_name = "" - thread_id = msg.get("thread_id") - id_like = (isinstance(thread_id, int) and not isinstance(thread_id, bool) - or isinstance(thread_id, str) and len(thread_id) <= 64) - if thread_id is not None and not id_like: - thread_id = None # Two shapes, and keeping them apart is what makes this deployable. # @@ -387,7 +383,7 @@ class ChatMixin: self._spawn(self._store_chat_message( chat_store, iteration=msg.get("iteration", 0), payload=raw, - thread_id=thread_id, sender_name=sender_name, + sender_name=sender_name, format=fmt, epoch=epoch, device=device, nonce=nonce, sig=sig, )) @@ -398,7 +394,6 @@ class ChatMixin: "sender_id": self._user_id, "sender_name": sender_name, "payload": payload, - "thread_id": thread_id, "timestamp": time.time(), "format": fmt, "epoch": epoch, @@ -613,7 +608,6 @@ class ChatMixin: "sender_id": m.sender_id, "sender_name": m.sender_name or names.get(m.sender_id, ""), "timestamp": m.timestamp, - "thread_id": m.thread_id, "format": m.format, "epoch": m.epoch, "device": m.device, diff --git a/packages/meshbay-node/tests/test_chat_is_bounded.py b/packages/meshbay-node/tests/test_chat_is_bounded.py index c48f26d..f98fd81 100644 --- a/packages/meshbay-node/tests/test_chat_is_bounded.py +++ b/packages/meshbay-node/tests/test_chat_is_bounded.py @@ -210,10 +210,10 @@ async def test_one_member_at_their_limit_has_not_spent_anyone_elses(ctx, store): async def test_the_clear_fields_are_what_they_claim_and_no_larger(ctx, store): """ - `sender_name` and `thread_id` travel in clear beside the sealed envelope, - which carries its own. They are stored on the operator's disk and relayed - to every member, so a megabyte of name or a list for a thread id is dropped, - not kept. + `sender_name` travels in clear beside the sealed envelope, which carries its + own. It is stored on the operator's disk and relayed to every member, so a + megabyte of name is dropped, not kept — and a `thread_id`, which an older + client still sends, is neither. """ alice = _session(ctx, store, user="alice", conn="c1") bob = _session(ctx, store, user="bob", conn="c2") @@ -225,17 +225,19 @@ async def test_the_clear_fields_are_what_they_claim_and_no_larger(ctx, store): stored = (await store.get_recent(limit=1))[0] assert stored.sender_name in ("", None) - assert stored.thread_id is None relayed = [m for m in bob.sent if m.get("type") == "chat_msg"] - assert relayed and relayed[-1]["sender_name"] == "" and relayed[-1]["thread_id"] is None + assert relayed and relayed[-1]["sender_name"] == "" and "thread_id" not in relayed[-1] -async def test_ordinary_clear_fields_pass_unchanged(ctx, store): +async def test_an_older_clients_message_is_taken_its_thread_id_ignored(ctx, store): alice = _session(ctx, store, user="alice", conn="c1") + bob = _session(ctx, store, user="bob", conn="c2") msg = _message(alice, size=64) msg["sender_name"] = "Alice" msg["thread_id"] = "42" alice._do_chat_message(msg) await _drain(ctx) stored = (await store.get_recent(limit=1))[0] - assert (stored.sender_name, stored.thread_id) == ("Alice", "42") + assert stored.sender_name == "Alice" + relayed = [m for m in bob.sent if m.get("type") == "chat_msg"] + assert relayed and "thread_id" not in relayed[-1] diff --git a/packages/meshbay-node/tests/test_chat_store.py b/packages/meshbay-node/tests/test_chat_store.py index 2533921..c4dff6d 100644 --- a/packages/meshbay-node/tests/test_chat_store.py +++ b/packages/meshbay-node/tests/test_chat_store.py @@ -28,7 +28,6 @@ async def test_save_and_retrieve(store): assert msgs[0].sender_id == "alice" assert msgs[0].iteration == 0 assert msgs[0].payload == b"hello" - assert msgs[0].thread_id is None @pytest.mark.asyncio @@ -52,15 +51,32 @@ async def test_get_messages_since(store): @pytest.mark.asyncio -async def test_thread_messages(store): - await store.save_message("alice", 0, b"root", thread_id="t1") - await store.save_message("bob", 1, b"reply", thread_id="t1") - await store.save_message("carol", 2, b"other") - - thread = await store.get_thread("t1") - assert len(thread) == 2 - assert thread[0].sender_id == "alice" - assert thread[1].sender_id == "bob" +async def test_the_thread_column_stays_for_an_older_node_and_its_index_goes(tmp_path): + """Chat has no threads; a node downgraded to a version that still names + `thread_id` in every query must keep reading its rows.""" + import sqlite3 + path = tmp_path / "chat.db" + with sqlite3.connect(path) as c: # as an older node left it + c.executescript(""" + CREATE TABLE messages (id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id TEXT NOT NULL, iteration INTEGER NOT NULL, + payload BLOB NOT NULL, timestamp REAL NOT NULL, + thread_id TEXT DEFAULT NULL, sender_name TEXT DEFAULT ''); + CREATE INDEX idx_messages_thread ON messages(thread_id); + INSERT INTO messages (sender_id, iteration, payload, timestamp, thread_id) + VALUES ('alice', 0, X'6869', 1.0, 't1');""") + s = ChatStore(path) + await s.open() + await s.save_message("bob", 1, b"now") + assert [m.payload for m in await s.get_recent()] == [b"hi", b"now"] + await s.close() + with sqlite3.connect(path) as c: + assert "thread_id" in [r[1] for r in c.execute("PRAGMA table_info(messages)")] + assert not c.execute("SELECT 1 FROM sqlite_master WHERE name = " + "'idx_messages_thread'").fetchone() + # What an older node selects still answers. + assert c.execute("SELECT thread_id FROM messages ORDER BY id").fetchall() == [ + ("t1",), (None,)] @pytest.mark.asyncio |