From 7575ef3e810dce4a0da33bbc716b859e198a1afc Mon Sep 17 00:00:00 2001 From: AI Bot Date: Sat, 27 Jun 2026 14:44:20 +0100 Subject: [PATCH 1/2] 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/2] 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;