eleven / messenger Public

server: drop the vestigial reactions table — legacy rows held raw session tokens (#726)

Reactions became encrypted message events (the chat.react fold) long ago;
nothing writes or reads the table — only two dead cleanup DELETEs remained
(one of which matched a user id against a token column, so it never deleted
anything). Legacy rows from the pre-event era stored the reactor's raw
session token in the clear. Drop the table on migrate (same rationale as the
old invites drop) and stop creating it for fresh DBs. Also corrects the
stale hub.go description in CLAUDE.md (react/unreact are not frame types).

Part of #724 (no plaintext on the server), step 1 of docs/no-plaintext.md.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 9fec5b2721de5ebabd9547b7d1549f96fc2c285a parent e51f771
3 files changed, +63 −32
  • CLAUDE.md +4 −2
  • db.go +13 −30
  • db_test.go +46 −0
diff --git a/CLAUDE.md b/CLAUDE.md
index da02578..86c5611 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -111,8 +111,10 @@ in, so deploy is one file (no cgo).
- `db.go` — SQLite (WAL, `modernc.org/sqlite`), all tables + queries. Migrations
are additive `CREATE TABLE IF NOT EXISTS`, so deploying over existing data is
safe.
-- `hub.go` — WebSocket fan-out (`coder/websocket`). Handles `msg` / `react` /
- `unreact`; ignores unknown types (e.g. the client keepalive `ping`).
+- `hub.go` — WebSocket fan-out (`coder/websocket`). Handles `msg` (incl.
+ relay-only ephemeral frames) / `edit` / `delete` / `typing`; ignores unknown
+ types (e.g. the client keepalive `ping`). Reactions are not a frame type —
+ they ride inside encrypted message events, folded client-side.
- `miniapps.go` — the mini-app server surface: scheduled "future messages"
(encrypted blobs delivered into a thread at a set time by a sweep goroutine)
and per-thread encrypted state snapshots with optimistic versioning.
diff --git a/db.go b/db.go
index c6a98a2..85293d5 100644
--- a/db.go
+++ b/db.go
@@ -268,13 +268,6 @@ CREATE TABLE IF NOT EXISTS apns_tokens (
sandbox INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
-CREATE TABLE IF NOT EXISTS reactions (
- message_id INTEGER NOT NULL,
- token TEXT NOT NULL,
- emoji TEXT NOT NULL,
- created_at INTEGER NOT NULL,
- PRIMARY KEY (message_id, token, emoji)
-);
-- Attachment/photo bytes. Two storage modes coexist so older DBs keep working:
-- * disk-backed (new): bytes live at blobDir/id; the data column is empty, size>0.
-- * legacy (pre-disk): bytes live in the data column; no file. getBlob falls back.
@@ -549,6 +542,12 @@ CREATE TABLE IF NOT EXISTS member_bots (
// stored in clear and are no longer accepted; drop the table so a stolen DB
// retains no plaintext credentials. Members re-authenticate via device tokens.
"DROP TABLE IF EXISTS invites",
+ // The reactions table is vestigial: reactions became encrypted message
+ // events (the chat.react fold), so nothing writes or reads it anymore.
+ // Legacy rows stored the reactor's RAW session token in the clear — drop
+ // the table so a stolen DB retains no plaintext credentials (same
+ // rationale as the invites drop above).
+ "DROP TABLE IF EXISTS reactions",
// Which user a push subscription belongs to, so removing a user can drop their
// subscriptions (sign-out clears them by token; removal has only the user id).
"ALTER TABLE push_subscriptions ADD COLUMN user_id TEXT",
@@ -1391,7 +1390,7 @@ func (db *DB) userPresenceInfo(userID string) (pinned string, isAgent bool) {
// removeUser deletes a user and all their traces (admin roster management): device
// tokens, thread membership + wrapped keys, DM-pair records, pending invites, their
-// instance-key copy, reactions, and their own messages — then cleans up any thread
+// instance-key copy, and their own messages — then cleans up any thread
// left with no members. Threads they merely shared (a DM/group with others) survive;
// the other members keep them.
func (db *DB) removeUser(id string) error {
@@ -1487,7 +1486,6 @@ func (db *DB) removeUser(id string) error {
{"DELETE FROM user_tiers WHERE user_id = ?", []any{id}},
{"DELETE FROM tier_keys WHERE user_id = ?", []any{id}},
{"DELETE FROM dm_pairs WHERE user_a = ? OR user_b = ?", []any{id, id}},
- {"DELETE FROM reactions WHERE token = ?", []any{id}},
{"DELETE FROM messages WHERE author_hash = ?", []any{id}},
{"DELETE FROM push_subscriptions WHERE user_id = ?", []any{id}},
{"DELETE FROM users WHERE id = ?", []any{id}},
@@ -2986,16 +2984,13 @@ func (db *DB) editMessage(id int64, blob, authorHash string, window time.Duratio
return Message{ID: id, Blob: blob, EditedAt: now.Unix()}, true, nil
}
-// deleteMessage hard-deletes a message (and its reactions) if authorHash is the
-// author — unsend-style, so the ciphertext is actually gone, not just hidden.
+// deleteMessage hard-deletes a message if authorHash is the author —
+// unsend-style, so the ciphertext is actually gone, not just hidden.
// Returns whether a row was removed (false = not the author / unknown id).
+// Reactions live inside encrypted message events, so there is nothing else
+// to clean up here.
func (db *DB) deleteMessage(id int64, authorHash string) (bool, error) {
- tx, err := db.sql.Begin()
- if err != nil {
- return false, err
- }
- defer tx.Rollback()
- res, err := tx.Exec("DELETE FROM messages WHERE id = ? AND author_hash = ?", id, authorHash)
+ res, err := db.sql.Exec("DELETE FROM messages WHERE id = ? AND author_hash = ?", id, authorHash)
if err != nil {
return false, err
}
@@ -3003,13 +2998,7 @@ func (db *DB) deleteMessage(id int64, authorHash string) (bool, error) {
if err != nil {
return false, err
}
- if n == 0 {
- return false, tx.Commit()
- }
- if _, err := tx.Exec("DELETE FROM reactions WHERE message_id = ?", id); err != nil {
- return false, err
- }
- return true, tx.Commit()
+ return n > 0, nil
}
// messagesBefore returns the `limit` messages immediately older than `before`
@@ -3833,9 +3822,3 @@ func (db *DB) deleteMemberBot(id string) (bool, error) {
return n > 0, nil
}
-// ---- reactions ----------------------------------------------------------
-//
-// A reaction is (message_id, token, emoji). The emoji is stored in the clear so
-// the server can count and de-duplicate them; message content stays encrypted.
-// The token identifies the reactor for toggling, never their screen name.
-
diff --git a/db_test.go b/db_test.go
index 31a71de..0c0f8e4 100644
--- a/db_test.go
+++ b/db_test.go
@@ -359,3 +359,49 @@ func TestThreadsAndRoster(t *testing.T) {
t.Fatalf("group members: want 3, got %d", len(m))
}
}
+
+// The reactions table is vestigial (reactions became encrypted message events)
+// and legacy rows held raw session tokens — migrate must drop it from old DBs,
+// and a fresh DB must never create it.
+func TestReactionsTableDropped(t *testing.T) {
+ dir := t.TempDir()
+
+ // Fresh DB: the table must not exist at all.
+ fresh := newTestDB(t)
+ var n int
+ if err := fresh.sql.QueryRow(
+ "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'reactions'").Scan(&n); err != nil {
+ t.Fatalf("sqlite_master: %v", err)
+ }
+ if n != 0 {
+ t.Fatal("fresh DB created a reactions table")
+ }
+
+ // Old DB: seed a legacy reactions table (with a raw-token row, as the old
+ // schema stored), then reopen — migrate must drop it.
+ path := dir + "/old.db"
+ old, err := openDB(path)
+ if err != nil {
+ t.Fatalf("openDB: %v", err)
+ }
+ if _, err := old.sql.Exec(`CREATE TABLE reactions (
+ message_id INTEGER NOT NULL, token TEXT NOT NULL, emoji TEXT NOT NULL,
+ created_at INTEGER NOT NULL, PRIMARY KEY (message_id, token, emoji));
+ INSERT INTO reactions VALUES (1, 'raw-session-token', '👍', 0)`); err != nil {
+ t.Fatalf("seed legacy table: %v", err)
+ }
+ old.sql.Close()
+
+ reopened, err := openDB(path)
+ if err != nil {
+ t.Fatalf("reopen: %v", err)
+ }
+ defer reopened.sql.Close()
+ if err := reopened.sql.QueryRow(
+ "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'reactions'").Scan(&n); err != nil {
+ t.Fatalf("sqlite_master after migrate: %v", err)
+ }
+ if n != 0 {
+ t.Fatal("migrate left the legacy reactions table (and its raw tokens) in place")
+ }
+}