From 7575ef3e810dce4a0da33bbc716b859e198a1afc Mon Sep 17 00:00:00 2001 From: AI Bot Date: Sat, 27 Jun 2026 14:44:20 +0100 Subject: [PATCH 1/6] feat: migrate messages content to ciphertext (#173) --- .../src/__tests__/conversations.cache.test.ts | 45 +------------- .../__tests__/conversations.routes.test.ts | 6 +- .../src/__tests__/messages.routes.test.ts | 6 +- apps/backend/src/db/schema.ts | 10 +-- apps/backend/src/lib/messages.ts | 6 +- apps/backend/src/routes/conversations.ts | 61 ------------------- apps/backend/src/socket/messaging.ts | 24 ++++---- 7 files changed, 24 insertions(+), 134 deletions(-) diff --git a/apps/backend/src/__tests__/conversations.cache.test.ts b/apps/backend/src/__tests__/conversations.cache.test.ts index 7e7f679..e97bcce 100644 --- a/apps/backend/src/__tests__/conversations.cache.test.ts +++ b/apps/backend/src/__tests__/conversations.cache.test.ts @@ -62,7 +62,7 @@ vi.mock('../db/schema.js', () => ({ id: 'id', conversationId: 'conversationId', senderId: 'senderId', - content: 'content', + ciphertext: 'ciphertext', createdAt: 'createdAt', deletedAt: 'deletedAt', }, @@ -189,50 +189,7 @@ describe('GET /conversations — Redis caching', () => { }); }); -describe('GET /conversations/:id/search', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockRedisInstance = { get: mockGet, setex: mockSetex, del: mockDel }; - }); - - it('returns 400 when the query is empty', async () => { - const res = await request(makeApp()).get('/conversations/conv-1/search?q= '); - - expect(res.status).toBe(400); - expect(mockFindFirst).not.toHaveBeenCalled(); - expect(mockExecute).not.toHaveBeenCalled(); - }); - - it('returns 403 when the user is not a conversation member', async () => { - mockFindFirst.mockResolvedValue(undefined); - const res = await request(makeApp()).get('/conversations/conv-1/search?q=hello'); - - expect(res.status).toBe(403); - expect(mockExecute).not.toHaveBeenCalled(); - }); - - it('returns ranked highlighted matches for conversation members', async () => { - const searchResults = [ - { - id: 'msg-1', - conversationId: 'conv-1', - senderId: TEST_USER_ID, - content: 'hello from stellar', - snippet: 'hello from stellar', - rank: '0.1', - }, - ]; - mockFindFirst.mockResolvedValue({ id: 'member-1' }); - mockExecute.mockResolvedValue(searchResults); - - const res = await request(makeApp()).get('/conversations/conv-1/search?q=hello'); - - expect(res.status).toBe(200); - expect(res.body).toEqual({ results: searchResults }); - expect(mockExecute).toHaveBeenCalledTimes(1); - }); -}); describe('GET /conversations — isArchived filter', () => { beforeEach(() => { diff --git a/apps/backend/src/__tests__/conversations.routes.test.ts b/apps/backend/src/__tests__/conversations.routes.test.ts index f402138..aec47d1 100644 --- a/apps/backend/src/__tests__/conversations.routes.test.ts +++ b/apps/backend/src/__tests__/conversations.routes.test.ts @@ -54,7 +54,7 @@ vi.mock('../db/schema.js', () => ({ id: 'id', conversationId: 'conversationId', senderId: 'senderId', - content: 'content', + ciphertext: 'ciphertext', createdAt: 'createdAt', deletedAt: 'deletedAt', }, @@ -138,7 +138,7 @@ describe('GET /conversations/:id', () => { id: 'msg-1', conversationId: 'conv-1', senderId: 'user-1', - content: 'hello', + ciphertext: 'hello', deletedAt: null, sender: { id: 'user-1', @@ -157,7 +157,7 @@ describe('GET /conversations/:id', () => { expect(res.status).toBe(200); expect(res.body.id).toBe('conv-1'); expect(res.body.messages).toHaveLength(1); - expect(res.body.messages[0].content).toBe('hello'); + expect(res.body.messages[0].ciphertext).toBe('hello'); }); }); diff --git a/apps/backend/src/__tests__/messages.routes.test.ts b/apps/backend/src/__tests__/messages.routes.test.ts index f48ae04..e39f0c6 100644 --- a/apps/backend/src/__tests__/messages.routes.test.ts +++ b/apps/backend/src/__tests__/messages.routes.test.ts @@ -41,7 +41,7 @@ vi.mock('../db/schema.js', () => ({ id: 'id', conversationId: 'conversationId', senderId: 'senderId', - content: 'content', + ciphertext: 'ciphertext', createdAt: 'createdAt', deletedAt: 'deletedAt', }, @@ -83,7 +83,7 @@ describe('DELETE /messages/:id', () => { id: 'msg-1', conversationId: 'conv-1', senderId: 'user-2', - content: 'hello', + ciphertext: 'hello', deletedAt: null, }); @@ -98,7 +98,7 @@ describe('DELETE /messages/:id', () => { id: 'msg-1', conversationId: 'conv-1', senderId: 'user-1', - content: 'hello', + ciphertext: 'hello', deletedAt: null, }); diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 9e09e99..e7b6adb 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -67,16 +67,10 @@ export const messages = pgTable( senderId: uuid('sender_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), - content: text('content').notNull(), + ciphertext: text('ciphertext'), createdAt: timestamp('created_at').notNull().defaultNow(), deletedAt: timestamp('deleted_at'), - }, - (table) => [ - index('messages_content_search_idx').using( - 'gin', - sql`to_tsvector('english', ${table.content})`, - ), - ], + } ); // ─── Devices & prekeys (issues #158, #159, #162) ───────────────────────────── diff --git a/apps/backend/src/lib/messages.ts b/apps/backend/src/lib/messages.ts index a07cb4c..b2b6e2a 100644 --- a/apps/backend/src/lib/messages.ts +++ b/apps/backend/src/lib/messages.ts @@ -1,15 +1,15 @@ type MessageLike = { - content: string | null; + ciphertext: string | null; deletedAt?: Date | null; }; export function serializeMessage( message: T, -): Omit & { content: string | null } { +): Omit & { ciphertext: string | null } { const { deletedAt, ...rest } = message; return { ...rest, - content: deletedAt ? null : message.content, + ciphertext: deletedAt ? null : message.ciphertext, }; } diff --git a/apps/backend/src/routes/conversations.ts b/apps/backend/src/routes/conversations.ts index 822070f..2451210 100644 --- a/apps/backend/src/routes/conversations.ts +++ b/apps/backend/src/routes/conversations.ts @@ -485,68 +485,7 @@ conversationsRouter.get('/:id/messages', async (req: AuthRequest, res) => { res.json({ messages: page, nextCursor }); }); -conversationsRouter.get('/:id/search', async (req: AuthRequest, res) => { - const userId = req.auth!.userId; - const conversationId = req.params['id'] as string | undefined; - const query = typeof req.query.q === 'string' ? req.query.q.trim() : ''; - - if (!conversationId) { - res.status(400).json({ error: 'Conversation id is required' }); - return; - } - if (!query) { - res.status(400).json({ error: 'Search query is required' }); - return; - } - - const membership = await db.query.conversationMembers.findFirst({ - where: and( - eq(conversationMembers.conversationId, conversationId), - eq(conversationMembers.userId, userId), - ), - }); - - if (!membership) { - res.status(403).json({ error: 'Not a member of this conversation' }); - return; - } - - const results = await db.execute<{ - id: string; - conversationId: string; - senderId: string; - content: string; - createdAt: Date; - snippet: string; - rank: string; - }>(sql` - WITH search_query AS ( - SELECT websearch_to_tsquery('english', ${query}) AS query - ) - SELECT - ${messages.id} AS "id", - ${messages.conversationId} AS "conversationId", - ${messages.senderId} AS "senderId", - ${messages.content} AS "content", - ${messages.createdAt} AS "createdAt", - ts_headline( - 'english', - ${messages.content}, - search_query.query, - 'StartSel=, StopSel=, MaxWords=24, MinWords=8, ShortWord=3, HighlightAll=false' - ) AS "snippet", - ts_rank_cd(to_tsvector('english', ${messages.content}), search_query.query) AS "rank" - FROM ${messages}, search_query - WHERE ${messages.conversationId} = ${conversationId} - AND ${messages.deletedAt} IS NULL - AND search_query.query @@ to_tsvector('english', ${messages.content}) - ORDER BY "rank" DESC, ${messages.createdAt} DESC - LIMIT ${SEARCH_RESULT_LIMIT} - `); - - res.json({ results }); -}); // PATCH /conversations/:id/settings — update muted/archived state for the authenticated user conversationsRouter.patch('/:id/settings', async (req: AuthRequest, res) => { diff --git a/apps/backend/src/socket/messaging.ts b/apps/backend/src/socket/messaging.ts index 17d3bab..5c22d28 100644 --- a/apps/backend/src/socket/messaging.ts +++ b/apps/backend/src/socket/messaging.ts @@ -35,13 +35,13 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void }); // ── send_message ─────────────────────────────────────────────────────────── - // Payload: { conversationId: string; content: string } + // Payload: { conversationId: string; ciphertext: string } // Persists the message and broadcasts it to all room members. - socket.on('send_message', async (payload: { conversationId: string; content: string }) => { - const { conversationId, content } = payload; + socket.on('send_message', async (payload: { conversationId: string; ciphertext: string }) => { + const { conversationId, ciphertext } = payload; - if (!content?.trim()) { - socket.emit('error', { event: 'send_message', message: 'Content must not be empty' }); + if (!ciphertext?.trim()) { + socket.emit('error', { event: 'send_message', message: 'Ciphertext must not be empty' }); return; } @@ -59,7 +59,7 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void const [message] = await db .insert(messages) - .values({ conversationId, senderId: userId, content: content.trim() }) + .values({ conversationId, senderId: userId, ciphertext: ciphertext.trim() }) .returning(); io.to(conversationId).emit('new_message', message); @@ -238,15 +238,15 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void }); // ── ask_assistant ────────────────────────────────────────────────────────── - // Payload: { conversationId: string; content: string } + // Payload: { conversationId: string; ciphertext: string } // Forwards to AI agent and posts reply from reserved assistant user. // Rate-limit: 5 requests per user per minute. const ASSISTANT_USER_ID = '00000000-0000-4000-8000-000000000000'; - socket.on('ask_assistant', async (payload: { conversationId: string; content: string }) => { - const { conversationId, content } = payload; + socket.on('ask_assistant', async (payload: { conversationId: string; ciphertext: string }) => { + const { conversationId, ciphertext } = payload; - if (!content?.trim().startsWith('@assistant')) { + if (!ciphertext?.trim().startsWith('@assistant')) { return; } @@ -284,7 +284,7 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - message: content, + message: ciphertext, conversation_id: conversationId, }), }); @@ -317,7 +317,7 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void .values({ conversationId, senderId: ASSISTANT_USER_ID, - content: data.reply, + ciphertext: data.reply, }) .returning(); From 2e17e89979291d2703dc8cacc051d8f986acce85 Mon Sep 17 00:00:00 2001 From: AI Bot Date: Sun, 28 Jun 2026 17:47:13 +0100 Subject: [PATCH 2/6] fix: prettier formatting violations in ciphertext migration --- apps/backend/src/__tests__/conversations.cache.test.ts | 2 -- apps/backend/src/db/schema.ts | 2 +- apps/backend/src/routes/conversations.ts | 4 ---- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/backend/src/__tests__/conversations.cache.test.ts b/apps/backend/src/__tests__/conversations.cache.test.ts index e97bcce..366d712 100644 --- a/apps/backend/src/__tests__/conversations.cache.test.ts +++ b/apps/backend/src/__tests__/conversations.cache.test.ts @@ -189,8 +189,6 @@ describe('GET /conversations — Redis caching', () => { }); }); - - describe('GET /conversations — isArchived filter', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index e7b6adb..5ba4fce 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -70,7 +70,7 @@ export const messages = pgTable( ciphertext: text('ciphertext'), createdAt: timestamp('created_at').notNull().defaultNow(), deletedAt: timestamp('deleted_at'), - } + }, ); // ─── Devices & prekeys (issues #158, #159, #162) ───────────────────────────── diff --git a/apps/backend/src/routes/conversations.ts b/apps/backend/src/routes/conversations.ts index 2451210..89bb61e 100644 --- a/apps/backend/src/routes/conversations.ts +++ b/apps/backend/src/routes/conversations.ts @@ -14,8 +14,6 @@ export const conversationsRouter: IRouter = Router(); conversationsRouter.use(requireAuth); -const SEARCH_RESULT_LIMIT = 20; - const conversationRelations = { members: { with: { @@ -485,8 +483,6 @@ conversationsRouter.get('/:id/messages', async (req: AuthRequest, res) => { res.json({ messages: page, nextCursor }); }); - - // PATCH /conversations/:id/settings — update muted/archived state for the authenticated user conversationsRouter.patch('/:id/settings', async (req: AuthRequest, res) => { const userId = req.auth!.userId; From 0aaa06b3f47eb8c85716327445b143dc99d2a0ce Mon Sep 17 00:00:00 2001 From: AI Bot Date: Mon, 29 Jun 2026 19:54:51 +0100 Subject: [PATCH 3/6] fix: reformat messages table to two-arg pgTable call for prettier --- apps/backend/src/db/schema.ts | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 5ba4fce..992a909 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -57,21 +57,18 @@ export const conversationMembers = pgTable('conversation_members', { joinedAt: timestamp('joined_at').notNull().defaultNow(), }); -export const messages = pgTable( - 'messages', - { - id: uuid('id').primaryKey().defaultRandom(), - conversationId: uuid('conversation_id') - .notNull() - .references(() => conversations.id, { onDelete: 'cascade' }), - senderId: uuid('sender_id') - .notNull() - .references(() => users.id, { onDelete: 'cascade' }), - ciphertext: text('ciphertext'), - createdAt: timestamp('created_at').notNull().defaultNow(), - deletedAt: timestamp('deleted_at'), - }, -); +export const messages = pgTable('messages', { + id: uuid('id').primaryKey().defaultRandom(), + conversationId: uuid('conversation_id') + .notNull() + .references(() => conversations.id, { onDelete: 'cascade' }), + senderId: uuid('sender_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + ciphertext: text('ciphertext'), + createdAt: timestamp('created_at').notNull().defaultNow(), + deletedAt: timestamp('deleted_at'), +}); // ─── Devices & prekeys (issues #158, #159, #162) ───────────────────────────── // From 1b8b196270e8680a8a5ba06117cfacbbbb9f2892 Mon Sep 17 00:00:00 2001 From: AI Bot Date: Mon, 29 Jun 2026 20:04:58 +0100 Subject: [PATCH 4/6] fix: add 501 stub for search endpoint and resolve merge conflict Search is disabled in E2EE environments since the server can no longer read ciphertext. Route returns 501, test updated to match. --- .../src/__tests__/conversations.cache.test.ts | 12 ++++++++++++ apps/backend/src/routes/conversations.ts | 5 +++++ 2 files changed, 17 insertions(+) diff --git a/apps/backend/src/__tests__/conversations.cache.test.ts b/apps/backend/src/__tests__/conversations.cache.test.ts index 366d712..4d74bea 100644 --- a/apps/backend/src/__tests__/conversations.cache.test.ts +++ b/apps/backend/src/__tests__/conversations.cache.test.ts @@ -189,6 +189,18 @@ describe('GET /conversations — Redis caching', () => { }); }); +describe('GET /conversations/:id/search', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns 501 for E2EE environments', async () => { + const res = await request(makeApp()).get('/conversations/conv-1/search?q=hello'); + + expect(res.status).toBe(501); + }); +}); + describe('GET /conversations — isArchived filter', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/backend/src/routes/conversations.ts b/apps/backend/src/routes/conversations.ts index 89bb61e..258b859 100644 --- a/apps/backend/src/routes/conversations.ts +++ b/apps/backend/src/routes/conversations.ts @@ -483,6 +483,11 @@ conversationsRouter.get('/:id/messages', async (req: AuthRequest, res) => { res.json({ messages: page, nextCursor }); }); +// GET /conversations/:id/search — disabled in E2EE environments (server cannot read ciphertext) +conversationsRouter.get('/:id/search', (_req: AuthRequest, res) => { + res.status(501).json({ error: 'Full-text search is not available in E2EE environments' }); +}); + // PATCH /conversations/:id/settings — update muted/archived state for the authenticated user conversationsRouter.patch('/:id/settings', async (req: AuthRequest, res) => { const userId = req.auth!.userId; From 96b0c44a3ea0fb42d7aac86be457c306e3706af6 Mon Sep 17 00:00:00 2001 From: AI Bot Date: Mon, 29 Jun 2026 20:10:12 +0100 Subject: [PATCH 5/6] feat: add DB migration for ciphertext column (#173) - Drop messages_content_search_idx (GIN index on plaintext content) - Add nullable ciphertext column to messages table - Migrate existing content values into ciphertext - Drop plaintext content column --- .../drizzle/0008_migrate_messages_ciphertext.sql | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 apps/backend/drizzle/0008_migrate_messages_ciphertext.sql diff --git a/apps/backend/drizzle/0008_migrate_messages_ciphertext.sql b/apps/backend/drizzle/0008_migrate_messages_ciphertext.sql new file mode 100644 index 0000000..6f0034e --- /dev/null +++ b/apps/backend/drizzle/0008_migrate_messages_ciphertext.sql @@ -0,0 +1,11 @@ +-- Drop the full-text search index that relied on the plaintext content column. +DROP INDEX IF EXISTS "messages_content_search_idx";--> statement-breakpoint + +-- Add the new nullable ciphertext column for single-blob E2EE messages. +ALTER TABLE "messages" ADD COLUMN "ciphertext" text;--> statement-breakpoint + +-- Migrate existing plaintext content into ciphertext so no data is lost. +UPDATE "messages" SET "ciphertext" = "content" WHERE "deleted_at" IS NULL;--> statement-breakpoint + +-- Drop the old plaintext content column. +ALTER TABLE "messages" DROP COLUMN "content"; From f26c74225e6b7d51c52f80fd8adb5e5adbec20d6 Mon Sep 17 00:00:00 2001 From: AI Bot Date: Mon, 29 Jun 2026 21:11:37 +0100 Subject: [PATCH 6/6] chore: remove commit_msg.txt temp file --- commit_msg.txt | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 commit_msg.txt diff --git a/commit_msg.txt b/commit_msg.txt deleted file mode 100644 index 114f277..0000000 --- a/commit_msg.txt +++ /dev/null @@ -1,10 +0,0 @@ -chore: resolve merge conflicts with upstream/main - -- schema.ts: keep upstream's extended messages table (senderDeviceId, - contentType, sequenceNumber, editsMessageId) + messageEnvelopes table -- messages.ts: use upstream's serializeMessage with envelope priority - and unavailable flag -- conversations.ts: use upstream's getConversationRelations(deviceId) - and 501 search stub -- messaging.ts: use upstream's full send_message/edit_message/typing - handlers; fix ask_assistant to pass ciphertext not content