From fd43d86ea828be1f24f4a5c7648384f04c333eab Mon Sep 17 00:00:00 2001 From: agessaman Date: Mon, 6 Jul 2026 22:46:49 -0700 Subject: [PATCH] fix: persist sender_prefix for signed room posts TXT_TYPE_SIGNED_PLAIN room posts carry a 4-byte author pubkey prefix (QueuedMessage.sender_prefix, added in openhop_core). The SQLite persistence path dropped it, so posts replayed from persistence showed a zero-padded author prefix in the app frame while posts synced from the live in-memory queue were correct. - add sender_prefix column (hex text, default '') to companion_messages with an ALTER TABLE migration for existing databases - store/return the prefix in companion_push/pop/load_messages - pass sender_prefix when rebuilding QueuedMessage from persistence in the frame server and the startup preload paths - add round-trip, default, migration, and rebuild tests --- repeater/companion/frame_server.py | 4 + repeater/data_acquisition/sqlite_handler.py | 45 +++++++- repeater/main.py | 8 ++ tests/test_companion_settings.py | 111 ++++++++++++++++++++ 4 files changed, 163 insertions(+), 5 deletions(-) diff --git a/repeater/companion/frame_server.py b/repeater/companion/frame_server.py index 388daa9..770f90d 100644 --- a/repeater/companion/frame_server.py +++ b/repeater/companion/frame_server.py @@ -91,6 +91,9 @@ class CompanionFrameServer(_BaseFrameServer): msg_dict = self.sqlite_handler.companion_pop_message(self.companion_hash) if not msg_dict: return None + sender_prefix = msg_dict.get("sender_prefix", b"") + if isinstance(sender_prefix, str): + sender_prefix = bytes.fromhex(sender_prefix) if sender_prefix else b"" return QueuedMessage( sender_key=msg_dict.get("sender_key", b""), txt_type=msg_dict.get("txt_type", 0), @@ -99,6 +102,7 @@ class CompanionFrameServer(_BaseFrameServer): is_channel=bool(msg_dict.get("is_channel", False)), channel_idx=msg_dict.get("channel_idx", 0), path_len=msg_dict.get("path_len", 0), + sender_prefix=sender_prefix, ) # ----------------------------------------------------------------- diff --git a/repeater/data_acquisition/sqlite_handler.py b/repeater/data_acquisition/sqlite_handler.py index 833755e..0ace176 100644 --- a/repeater/data_acquisition/sqlite_handler.py +++ b/repeater/data_acquisition/sqlite_handler.py @@ -426,6 +426,7 @@ class SQLiteHandler: is_channel INTEGER NOT NULL DEFAULT 0, channel_idx INTEGER NOT NULL DEFAULT 0, path_len INTEGER NOT NULL DEFAULT 0, + sender_prefix TEXT NOT NULL DEFAULT '', packet_hash TEXT, created_at REAL NOT NULL ) @@ -581,6 +582,30 @@ class SQLiteHandler: ) logger.info(f"Migration '{migration_name}' applied successfully") + # Migration 10: Add sender_prefix column (hex text) to + # companion_messages. TXT_TYPE_SIGNED_PLAIN room posts carry a + # 4-byte author pubkey prefix; without it, posts replayed from + # SQLite show a zero-padded author in the app frame. + migration_name = "add_sender_prefix_to_companion_messages" + existing = conn.execute( + "SELECT migration_name FROM migrations WHERE migration_name = ?", + (migration_name,), + ).fetchone() + if not existing: + cursor = conn.execute("PRAGMA table_info(companion_messages)") + columns = [column[1] for column in cursor.fetchall()] + if "sender_prefix" not in columns: + conn.execute( + "ALTER TABLE companion_messages " + "ADD COLUMN sender_prefix TEXT NOT NULL DEFAULT ''" + ) + logger.info("Added sender_prefix column to companion_messages table") + conn.execute( + "INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)", + (migration_name, time.time()), + ) + logger.info(f"Migration '{migration_name}' applied successfully") + conn.commit() except Exception as e: @@ -2652,13 +2677,17 @@ class SQLiteHandler: conn.row_factory = sqlite3.Row cursor = conn.execute( """ - SELECT sender_key, txt_type, timestamp, text, is_channel, channel_idx, path_len + SELECT sender_key, txt_type, timestamp, text, is_channel, channel_idx, + path_len, sender_prefix FROM companion_messages WHERE companion_hash = ? ORDER BY created_at ASC LIMIT ? """, (companion_hash, limit), ) - return [dict(row) for row in cursor.fetchall()] + rows = [dict(row) for row in cursor.fetchall()] + for msg in rows: + msg["sender_prefix"] = bytes.fromhex(msg.get("sender_prefix") or "") + return rows except Exception as e: logger.error(f"Failed to load companion messages: {e}") return [] @@ -2683,13 +2712,16 @@ class SQLiteHandler: if isinstance(packet_hash, bytes): packet_hash = packet_hash.decode("utf-8", errors="replace") if packet_hash else None sender_key = msg.get("sender_key", b"") + sender_prefix = msg.get("sender_prefix", b"") + if not isinstance(sender_prefix, str): + sender_prefix = bytes(sender_prefix or b"").hex() with self._connect() as conn: cursor = conn.execute( """ INSERT OR IGNORE INTO companion_messages (companion_hash, sender_key, txt_type, timestamp, text, - is_channel, channel_idx, path_len, packet_hash, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + is_channel, channel_idx, path_len, sender_prefix, packet_hash, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( companion_hash, @@ -2700,6 +2732,7 @@ class SQLiteHandler: int(msg.get("is_channel", False)), msg.get("channel_idx", 0), msg.get("path_len", 0), + sender_prefix, packet_hash, time.time(), ), @@ -2732,7 +2765,8 @@ class SQLiteHandler: conn.row_factory = sqlite3.Row cursor = conn.execute( """ - SELECT id, sender_key, txt_type, timestamp, text, is_channel, channel_idx, path_len + SELECT id, sender_key, txt_type, timestamp, text, is_channel, channel_idx, + path_len, sender_prefix FROM companion_messages WHERE companion_hash = ? ORDER BY created_at ASC LIMIT 1 """, @@ -2742,6 +2776,7 @@ class SQLiteHandler: if not row: return None msg = dict(row) + msg["sender_prefix"] = bytes.fromhex(msg.get("sender_prefix") or "") conn.execute("DELETE FROM companion_messages WHERE id = ?", (msg["id"],)) conn.commit() return {k: v for k, v in msg.items() if k != "id"} diff --git a/repeater/main.py b/repeater/main.py index 9c95d2e..7612aeb 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -656,6 +656,9 @@ class RepeaterDaemon: sk = msg_dict.get("sender_key", b"") if isinstance(sk, str): sk = bytes.fromhex(sk) + sp = msg_dict.get("sender_prefix", b"") + if isinstance(sp, str): + sp = bytes.fromhex(sp) if sp else b"" bridge.message_queue.push( QueuedMessage( sender_key=sk, @@ -665,6 +668,7 @@ class RepeaterDaemon: is_channel=bool(msg_dict.get("is_channel", False)), channel_idx=msg_dict.get("channel_idx", 0), path_len=msg_dict.get("path_len", 0), + sender_prefix=sp, ) ) @@ -842,6 +846,9 @@ class RepeaterDaemon: sk = msg_dict.get("sender_key", b"") if isinstance(sk, str): sk = bytes.fromhex(sk) + sp = msg_dict.get("sender_prefix", b"") + if isinstance(sp, str): + sp = bytes.fromhex(sp) if sp else b"" bridge.message_queue.push( QueuedMessage( sender_key=sk, @@ -851,6 +858,7 @@ class RepeaterDaemon: is_channel=bool(msg_dict.get("is_channel", False)), channel_idx=msg_dict.get("channel_idx", 0), path_len=msg_dict.get("path_len", 0), + sender_prefix=sp, ) ) diff --git a/tests/test_companion_settings.py b/tests/test_companion_settings.py index ea423d7..9c95cd9 100644 --- a/tests/test_companion_settings.py +++ b/tests/test_companion_settings.py @@ -196,6 +196,117 @@ class TestSqliteRetentionTrim: assert len(h.companion_load_messages("0x02")) == 3 +class TestSenderPrefixPersistence: + """sender_prefix (signed room-post author prefix) survives the SQLite round-trip.""" + + PREFIX = b"\xaa\xbb\xcc\xdd" + + @staticmethod + def _handler(tmp_path): + from repeater.data_acquisition.sqlite_handler import SQLiteHandler + + return SQLiteHandler(tmp_path) + + def _push(self, h, sender_prefix=PREFIX): + return h.companion_push_message( + "0x01", + { + "sender_key": b"\x01" * 32, + "txt_type": 2, + "timestamp": 42, + "text": "signed post", + "sender_prefix": sender_prefix, + "packet_hash": "ph-1", + }, + ) + + def test_push_pop_round_trip(self, tmp_path): + h = self._handler(tmp_path) + assert self._push(h) + msg = h.companion_pop_message("0x01") + assert msg["sender_prefix"] == self.PREFIX + assert msg["text"] == "signed post" + + def test_load_messages_returns_prefix_bytes(self, tmp_path): + h = self._handler(tmp_path) + assert self._push(h) + msgs = h.companion_load_messages("0x01") + assert len(msgs) == 1 + assert msgs[0]["sender_prefix"] == self.PREFIX + + def test_missing_prefix_defaults_empty(self, tmp_path): + h = self._handler(tmp_path) + assert h.companion_push_message( + "0x01", {"text": "plain", "timestamp": 1, "packet_hash": "ph-2"} + ) + msg = h.companion_pop_message("0x01") + assert msg["sender_prefix"] == b"" + + def test_migration_adds_column_to_existing_db(self, tmp_path): + import sqlite3 + + # Build a current DB, then rewind companion_messages to the + # pre-sender_prefix schema and drop the migration marker. + h = self._handler(tmp_path) + conn = sqlite3.connect(str(h.sqlite_path)) + conn.execute( + "DELETE FROM migrations " + "WHERE migration_name = 'add_sender_prefix_to_companion_messages'" + ) + conn.execute("ALTER TABLE companion_messages RENAME TO companion_messages_old") + conn.execute( + """ + CREATE TABLE companion_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + companion_hash TEXT NOT NULL, + sender_key BLOB NOT NULL, + txt_type INTEGER NOT NULL DEFAULT 0, + timestamp INTEGER NOT NULL DEFAULT 0, + text TEXT NOT NULL, + is_channel INTEGER NOT NULL DEFAULT 0, + channel_idx INTEGER NOT NULL DEFAULT 0, + path_len INTEGER NOT NULL DEFAULT 0, + packet_hash TEXT, + created_at REAL NOT NULL + ) + """ + ) + conn.execute("DROP TABLE companion_messages_old") + conn.execute( + "INSERT INTO companion_messages " + "(companion_hash, sender_key, text, created_at) VALUES ('0x01', X'01', 'old', 1.0)" + ) + conn.commit() + conn.close() + + h2 = self._handler(tmp_path) # re-runs migrations + # Pre-migration row decodes with an empty prefix. + old = h2.companion_pop_message("0x01") + assert old["sender_prefix"] == b"" + # New rows round-trip through the migrated column. + assert self._push(h2) + assert h2.companion_pop_message("0x01")["sender_prefix"] == self.PREFIX + + def test_sync_next_from_persistence_rebuilds_prefix(self): + from repeater.companion.frame_server import CompanionFrameServer + + fs = CompanionFrameServer.__new__(CompanionFrameServer) + fs.sqlite_handler = MagicMock() + fs.companion_hash = "0x01" + fs.sqlite_handler.companion_pop_message.return_value = { + "sender_key": b"\x01" * 32, + "txt_type": 2, + "timestamp": 42, + "text": "signed post", + "is_channel": 0, + "channel_idx": 0, + "path_len": 0, + "sender_prefix": self.PREFIX, + } + msg = fs._sync_next_from_persistence() + assert msg.sender_prefix == self.PREFIX + + class TestTrimContactsOnOverflowPolicy: @staticmethod def _contacts(n, favourites=0):