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
This commit is contained in:
agessaman
2026-07-06 22:46:49 -07:00
parent 700a38b8c1
commit fd43d86ea8
4 changed files with 163 additions and 5 deletions
+4
View File
@@ -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,
)
# -----------------------------------------------------------------
+40 -5
View File
@@ -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"}
+8
View File
@@ -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,
)
)
+111
View File
@@ -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):