From 041e8fc0020d1f5831689a7adf61a2fbbe925b6c Mon Sep 17 00:00:00 2001 From: testersweb0-bug Date: Sun, 28 Jun 2026 17:28:33 +0100 Subject: [PATCH 1/5] feat: implement E2EE messaging envelopes and message retraction --- .gitignore | 7 ++ apps/backend/drizzle.config.d.ts | 3 + apps/backend/drizzle.config.d.ts.map | 1 + apps/backend/drizzle.config.js | 10 ++ apps/backend/drizzle.config.js.map | 1 + .../src/__tests__/conversations.cache.test.ts | 40 +------- .../__tests__/conversations.routes.test.ts | 7 +- .../src/__tests__/messages.routes.test.ts | 7 +- apps/backend/src/db/schema.ts | 44 +++++++-- apps/backend/src/lib/messages.ts | 41 +++++++- apps/backend/src/routes/conversations.ts | 91 +++++------------- apps/backend/src/routes/messages.ts | 6 +- apps/backend/src/routes/treasury.ts | 6 +- apps/backend/src/socket/messaging.ts | 96 +++++++++++++++++-- apps/backend/vitest.config.d.ts | 3 + apps/backend/vitest.config.d.ts.map | 1 + apps/backend/vitest.config.js | 8 ++ apps/backend/vitest.config.js.map | 1 + 18 files changed, 242 insertions(+), 131 deletions(-) create mode 100644 apps/backend/drizzle.config.d.ts create mode 100644 apps/backend/drizzle.config.d.ts.map create mode 100644 apps/backend/drizzle.config.js create mode 100644 apps/backend/drizzle.config.js.map create mode 100644 apps/backend/vitest.config.d.ts create mode 100644 apps/backend/vitest.config.d.ts.map create mode 100644 apps/backend/vitest.config.js create mode 100644 apps/backend/vitest.config.js.map diff --git a/.gitignore b/.gitignore index 5702e0a..8f78b68 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,12 @@ target/ .DS_Store .turbo/ +# TypeScript build artifacts +apps/backend/src/**/*.js +apps/backend/src/**/*.js.map +apps/backend/src/**/*.d.ts +apps/backend/src/**/*.d.ts.map + + ISSUES.md IMPLEMENTATION_DOCS.md \ No newline at end of file diff --git a/apps/backend/drizzle.config.d.ts b/apps/backend/drizzle.config.d.ts new file mode 100644 index 0000000..48f7c2e --- /dev/null +++ b/apps/backend/drizzle.config.d.ts @@ -0,0 +1,3 @@ +declare const _default: import("drizzle-kit").Config; +export default _default; +//# sourceMappingURL=drizzle.config.d.ts.map \ No newline at end of file diff --git a/apps/backend/drizzle.config.d.ts.map b/apps/backend/drizzle.config.d.ts.map new file mode 100644 index 0000000..c62be7f --- /dev/null +++ b/apps/backend/drizzle.config.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"drizzle.config.d.ts","sourceRoot":"","sources":["drizzle.config.ts"],"names":[],"mappings":";AAEA,wBAOG"} \ No newline at end of file diff --git a/apps/backend/drizzle.config.js b/apps/backend/drizzle.config.js new file mode 100644 index 0000000..63ca54a --- /dev/null +++ b/apps/backend/drizzle.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit'; +export default defineConfig({ + schema: './src/db/schema.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env['DATABASE_URL'] ?? '', + }, +}); +//# sourceMappingURL=drizzle.config.js.map \ No newline at end of file diff --git a/apps/backend/drizzle.config.js.map b/apps/backend/drizzle.config.js.map new file mode 100644 index 0000000..c6aaa2d --- /dev/null +++ b/apps/backend/drizzle.config.js.map @@ -0,0 +1 @@ +{"version":3,"file":"drizzle.config.js","sourceRoot":"","sources":["drizzle.config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,eAAe,YAAY,CAAC;IAC1B,MAAM,EAAE,oBAAoB;IAC5B,GAAG,EAAE,WAAW;IAChB,OAAO,EAAE,YAAY;IACrB,aAAa,EAAE;QACb,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,IAAI,EAAE;KACvC;CACF,CAAC,CAAC"} \ No newline at end of file diff --git a/apps/backend/src/__tests__/conversations.cache.test.ts b/apps/backend/src/__tests__/conversations.cache.test.ts index 7e7f679..050d891 100644 --- a/apps/backend/src/__tests__/conversations.cache.test.ts +++ b/apps/backend/src/__tests__/conversations.cache.test.ts @@ -66,6 +66,7 @@ vi.mock('../db/schema.js', () => ({ createdAt: 'createdAt', deletedAt: 'deletedAt', }, + messageEnvelopes: { recipientDeviceId: 'recipientDeviceId' }, tokenTransfers: {}, })); vi.mock('drizzle-orm', () => { @@ -94,7 +95,7 @@ const TEST_USER_ID = 'user-test-123'; vi.mock('../middleware/auth.js', () => ({ requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { auth: { userId: string } }).auth = { userId: TEST_USER_ID }; + (req as express.Request & { auth: { userId: string; deviceId: string } }).auth = { userId: TEST_USER_ID, deviceId: 'device-test-123' }; next(); }, })); @@ -192,45 +193,12 @@ 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); - + it('returns 501 for E2EE environments', async () => { 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); + expect(res.status).toBe(501); }); }); diff --git a/apps/backend/src/__tests__/conversations.routes.test.ts b/apps/backend/src/__tests__/conversations.routes.test.ts index f402138..6307fe7 100644 --- a/apps/backend/src/__tests__/conversations.routes.test.ts +++ b/apps/backend/src/__tests__/conversations.routes.test.ts @@ -58,6 +58,7 @@ vi.mock('../db/schema.js', () => ({ createdAt: 'createdAt', deletedAt: 'deletedAt', }, + messageEnvelopes: { recipientDeviceId: 'recipientDeviceId' }, tokenTransfers: {}, })); @@ -73,7 +74,7 @@ vi.mock('drizzle-orm', () => ({ vi.mock('../middleware/auth.js', () => ({ requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { auth: { userId: string } }).auth = { userId: 'user-1' }; + (req as express.Request & { auth: { userId: string; deviceId: string } }).auth = { userId: 'user-1', deviceId: 'device-1' }; next(); }, })); @@ -138,7 +139,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 +158,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..aa5361b 100644 --- a/apps/backend/src/__tests__/messages.routes.test.ts +++ b/apps/backend/src/__tests__/messages.routes.test.ts @@ -8,6 +8,7 @@ const mockUpdate = vi.fn(); const mockEmit = vi.fn(); const mockTo = vi.fn(() => ({ emit: mockEmit })); +const mockDelete = vi.fn(); let mockSocketServer: { to: typeof mockTo } | null = { to: mockTo }; vi.mock('../lib/socket.js', () => ({ @@ -31,6 +32,7 @@ vi.mock('../db/index.js', () => ({ conversationMembers: { findMany: mockFindMembers }, }, update: mockUpdate, + delete: mockDelete, }, })); @@ -45,6 +47,7 @@ vi.mock('../db/schema.js', () => ({ createdAt: 'createdAt', deletedAt: 'deletedAt', }, + messageEnvelopes: { messageId: 'messageId' }, tokenTransfers: {}, })); @@ -104,14 +107,16 @@ describe('DELETE /messages/:id', () => { const setFn = vi.fn().mockReturnThis(); const whereFn = vi.fn().mockResolvedValue([{ conversationId: 'conv-1' }]); + const deleteWhereFn = vi.fn().mockResolvedValue([]); mockUpdate.mockReturnValue({ set: setFn }); setFn.mockReturnValue({ where: whereFn }); + mockDelete.mockReturnValue({ where: deleteWhereFn }); mockFindMembers.mockResolvedValue([{ userId: 'user-1' }, { userId: 'user-2' }]); const res = await request(makeApp()).delete('/messages/msg-1'); expect(res.status).toBe(204); - expect(setFn).toHaveBeenCalledWith({ deletedAt: expect.any(Date) }); + expect(setFn).toHaveBeenCalledWith({ deletedAt: expect.any(Date), ciphertext: null }); expect(mockTo).toHaveBeenCalledWith('conv-1'); expect(mockEmit).toHaveBeenCalledWith('message_deleted', { messageId: 'msg-1', diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 9e09e99..a118ed5 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -7,6 +7,7 @@ import { pgEnum, index, integer, + serial, uniqueIndex, } from 'drizzle-orm/pg-core'; import { relations, sql } from 'drizzle-orm'; @@ -67,15 +68,36 @@ export const messages = pgTable( senderId: uuid('sender_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), - content: text('content').notNull(), + senderDeviceId: uuid('sender_device_id').references(() => userDevices.id, { onDelete: 'set null' }), + contentType: text('content_type').notNull().default('text/plain'), + sequenceNumber: serial('sequence_number'), + ciphertext: text('ciphertext'), createdAt: timestamp('created_at').notNull().defaultNow(), deletedAt: timestamp('deleted_at'), + } +); + +export const messageEnvelopes = pgTable( + 'message_envelopes', + { + id: uuid('id').primaryKey().defaultRandom(), + messageId: uuid('message_id') + .notNull() + .references(() => messages.id, { onDelete: 'cascade' }), + recipientDeviceId: uuid('recipient_device_id') + .notNull() + .references(() => userDevices.id, { onDelete: 'cascade' }), + recipientUserId: uuid('recipient_user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + ciphertext: text('ciphertext').notNull(), + deliveredAt: timestamp('delivered_at'), + readAt: timestamp('read_at'), + createdAt: timestamp('created_at').notNull().defaultNow(), }, (table) => [ - index('messages_content_search_idx').using( - 'gin', - sql`to_tsvector('english', ${table.content})`, - ), + index('me_recipient_device_created_idx').on(table.recipientDeviceId, table.createdAt), + index('me_message_idx').on(table.messageId), ], ); @@ -259,12 +281,20 @@ export const conversationMembersRelations = relations(conversationMembers, ({ on user: one(users, { fields: [conversationMembers.userId], references: [users.id] }), })); -export const messagesRelations = relations(messages, ({ one }) => ({ +export const messagesRelations = relations(messages, ({ one, many }) => ({ conversation: one(conversations, { fields: [messages.conversationId], references: [conversations.id], }), sender: one(users, { fields: [messages.senderId], references: [users.id] }), + senderDevice: one(userDevices, { fields: [messages.senderDeviceId], references: [userDevices.id] }), + envelopes: many(messageEnvelopes), +})); + +export const messageEnvelopesRelations = relations(messageEnvelopes, ({ one }) => ({ + message: one(messages, { fields: [messageEnvelopes.messageId], references: [messages.id] }), + recipientDevice: one(userDevices, { fields: [messageEnvelopes.recipientDeviceId], references: [userDevices.id] }), + recipientUser: one(users, { fields: [messageEnvelopes.recipientUserId], references: [users.id] }), })); export const tokenTransfersRelations = relations(tokenTransfers, ({ one }) => ({ @@ -303,6 +333,8 @@ export type NewConversation = typeof conversations.$inferInsert; export type ConversationMember = typeof conversationMembers.$inferSelect; export type Message = typeof messages.$inferSelect; export type NewMessage = typeof messages.$inferInsert; +export type MessageEnvelope = typeof messageEnvelopes.$inferSelect; +export type NewMessageEnvelope = typeof messageEnvelopes.$inferInsert; export type TokenTransfer = typeof tokenTransfers.$inferSelect; export type NewTokenTransfer = typeof tokenTransfers.$inferInsert; export type Device = typeof devices.$inferSelect; diff --git a/apps/backend/src/lib/messages.ts b/apps/backend/src/lib/messages.ts index a07cb4c..30dc098 100644 --- a/apps/backend/src/lib/messages.ts +++ b/apps/backend/src/lib/messages.ts @@ -1,15 +1,48 @@ type MessageLike = { - content: string | null; + id: string; + senderId: string; + senderDeviceId?: string | null; + contentType: string; + sequenceNumber: number; + createdAt: Date; + ciphertext?: string | null; deletedAt?: Date | null; + envelopes?: Array<{ ciphertext: string }>; + [key: string]: any; }; export function serializeMessage( message: T, -): Omit & { content: string | null } { - const { deletedAt, ...rest } = message; +): Omit & { ciphertext: string | null; unavailable?: boolean } { + const { deletedAt, envelopes, ciphertext: baseCiphertext, ...rest } = message; + if (deletedAt) { + return { + ...rest, + ciphertext: null, + }; + } + + // If there's an envelope, its ciphertext takes precedence. + if (envelopes && envelopes.length > 0) { + return { + ...rest, + ciphertext: envelopes[0]!.ciphertext, + }; + } + + // If no envelope but we have base ciphertext (e.g. system message or legacy), use it. + if (baseCiphertext) { + return { + ...rest, + ciphertext: baseCiphertext, + }; + } + + // Otherwise, it's unavailable. return { ...rest, - content: deletedAt ? null : message.content, + ciphertext: null, + unavailable: true, }; } diff --git a/apps/backend/src/routes/conversations.ts b/apps/backend/src/routes/conversations.ts index 822070f..6c1bb31 100644 --- a/apps/backend/src/routes/conversations.ts +++ b/apps/backend/src/routes/conversations.ts @@ -2,7 +2,7 @@ import { Router } from 'express'; import type { IRouter } from 'express'; import { asc, and, count, desc, eq, lt, sql, ne } from 'drizzle-orm'; import { db } from '../db/index.js'; -import { conversationMembers, conversations, messages, tokenTransfers } from '../db/schema.js'; +import { conversationMembers, conversations, messages, tokenTransfers, messageEnvelopes } from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { redis, CONV_CACHE_TTL, convCacheKey } from '../lib/redis.js'; import { invalidateConversationCaches } from '../lib/conversationCache.js'; @@ -16,7 +16,7 @@ conversationsRouter.use(requireAuth); const SEARCH_RESULT_LIMIT = 20; -const conversationRelations = { +const getConversationRelations = (deviceId: string) => ({ members: { with: { user: { @@ -28,9 +28,15 @@ const conversationRelations = { messages: { orderBy: desc(messages.createdAt), limit: 1, - with: { sender: { columns: { id: true, username: true, avatarUrl: true } } }, + with: { + sender: { columns: { id: true, username: true, avatarUrl: true } }, + envelopes: { + where: eq(messageEnvelopes.recipientDeviceId, deviceId), + limit: 1, + } + }, }, -} as const; +}); type ConversationPayload = { messages?: Array>; @@ -40,7 +46,7 @@ type ConversationPayload = { function serializeConversation(conversation: T): T { return { ...conversation, - messages: (conversation.messages ?? []).map((message) => serializeMessage(message)), + messages: (conversation.messages ?? []).map((message) => serializeMessage(message as any)) as any, }; } @@ -93,9 +99,9 @@ conversationsRouter.get('/', async (req: AuthRequest, res) => { showArchived ? undefined : ne(conversationMembers.isArchived, true), ), with: { - conversation: conversationRelations as never, + conversation: getConversationRelations(req.auth!.deviceId) as never, }, - })) as unknown as Array<{ conversationId: string; conversation: ConversationPayload }>; + })) as unknown as Array<{ conversationId: string; isMuted: boolean; isArchived: boolean; conversation: ConversationPayload }>; // Single subquery for message counts — no N+1 const conversationIds = memberships.map((m) => m.conversationId); @@ -177,7 +183,7 @@ conversationsRouter.get('/:id', async (req: AuthRequest, res) => { const conversation = (await db.query.conversations.findFirst({ where: eq(conversations.id, conversationId), - with: conversationRelations as never, + with: getConversationRelations(req.auth!.deviceId) as never, })) as ConversationPayload | undefined; if (!conversation) { @@ -471,7 +477,13 @@ conversationsRouter.get('/:id/messages', async (req: AuthRequest, res) => { : eq(messages.conversationId, conversationId), orderBy: desc(messages.createdAt), limit: limit + 1, - with: { sender: { columns: { id: true, username: true, avatarUrl: true } } }, + with: { + sender: { columns: { id: true, username: true, avatarUrl: true } }, + envelopes: { + where: eq(messageEnvelopes.recipientDeviceId, req.auth!.deviceId), + limit: 1, + } + }, }); const hasMore = rows.length > limit; @@ -486,66 +498,7 @@ conversationsRouter.get('/:id/messages', async (req: AuthRequest, res) => { }); 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 }); + res.status(501).json({ error: 'Search is not supported in E2EE conversations' }); }); // PATCH /conversations/:id/settings — update muted/archived state for the authenticated user diff --git a/apps/backend/src/routes/messages.ts b/apps/backend/src/routes/messages.ts index 0c3838b..5bea9bc 100644 --- a/apps/backend/src/routes/messages.ts +++ b/apps/backend/src/routes/messages.ts @@ -2,7 +2,7 @@ import { Router } from 'express'; import type { IRouter } from 'express'; import { and, eq } from 'drizzle-orm'; import { db } from '../db/index.js'; -import { conversationMembers, messages } from '../db/schema.js'; +import { conversationMembers, messages, messageEnvelopes } from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { invalidateConversationCaches } from '../lib/conversationCache.js'; import { getSocketServer } from '../lib/socket.js'; @@ -36,8 +36,10 @@ messagesRouter.delete('/:id', async (req: AuthRequest, res) => { await db .update(messages) - .set({ deletedAt: new Date() }) + .set({ deletedAt: new Date(), ciphertext: null }) .where(and(eq(messages.id, messageId), eq(messages.senderId, userId))); + + await db.delete(messageEnvelopes).where(eq(messageEnvelopes.messageId, messageId)); getSocketServer()?.to(message.conversationId).emit('message_deleted', { messageId: message.id, diff --git a/apps/backend/src/routes/treasury.ts b/apps/backend/src/routes/treasury.ts index 660f768..303c255 100644 --- a/apps/backend/src/routes/treasury.ts +++ b/apps/backend/src/routes/treasury.ts @@ -1,9 +1,13 @@ import { Router } from 'express'; +import type { IRouter } from 'express'; import { z } from 'zod'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; +import { and, desc, eq } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { conversationMembers, treasuryProposals } from '../db/schema.js'; -export const treasuryRouter = Router(); +export const treasuryRouter: IRouter = Router(); treasuryRouter.use(requireAuth); diff --git a/apps/backend/src/socket/messaging.ts b/apps/backend/src/socket/messaging.ts index 17d3bab..a6cecec 100644 --- a/apps/backend/src/socket/messaging.ts +++ b/apps/backend/src/socket/messaging.ts @@ -1,7 +1,7 @@ import type { Server } from 'socket.io'; -import { and, eq, lt, desc, sql } from 'drizzle-orm'; +import { and, eq, lt, desc, sql, inArray } from 'drizzle-orm'; import { db } from '../db/index.js'; -import { conversations, conversationMembers, messages } from '../db/schema.js'; +import { conversations, conversationMembers, messages, messageEnvelopes, userDevices } from '../db/schema.js'; import type { AuthSocket } from '../middleware/socketAuth.js'; import { invalidateConversationCaches } from '../lib/conversationCache.js'; import { serializeMessage } from '../lib/messages.js'; @@ -35,13 +35,25 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void }); // ── send_message ─────────────────────────────────────────────────────────── - // Payload: { conversationId: string; content: string } + // Payload: { conversationId, messageId, contentType, ciphertext, envelopes } // 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; + messageId: string; + contentType?: string; + ciphertext?: string; + envelopes?: Array<{ recipientDeviceId: string; ciphertext: string }>; + }) => { + const { conversationId, messageId, contentType, ciphertext, envelopes } = payload; + const deviceId = socket.auth!.deviceId; + + if (!messageId) { + socket.emit('error', { event: 'send_message', message: 'messageId is required' }); + return; + } - if (!content?.trim()) { - socket.emit('error', { event: 'send_message', message: 'Content must not be empty' }); + if (!ciphertext?.trim() && (!envelopes || envelopes.length === 0)) { + socket.emit('error', { event: 'send_message', message: 'Message content is empty' }); return; } @@ -57,11 +69,54 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void return; } + // Idempotency check + const existing = await db.query.messages.findFirst({ + where: eq(messages.id, messageId), + columns: { sequenceNumber: true }, + }); + + if (existing) { + socket.emit('message_ack', { messageId, sequenceNumber: existing.sequenceNumber }); + return; + } + const [message] = await db .insert(messages) - .values({ conversationId, senderId: userId, content: content.trim() }) + .values({ + id: messageId, + conversationId, + senderId: userId, + senderDeviceId: deviceId, + contentType: contentType || 'text/plain', + ciphertext: ciphertext || null, + }) .returning(); + if (envelopes && envelopes.length > 0) { + const deviceIds = envelopes.map(e => e.recipientDeviceId); + const devicesList = await db.query.userDevices.findMany({ + where: inArray(userDevices.id, deviceIds), + columns: { id: true, userId: true } + }); + const deviceToUser = new Map(devicesList.map(d => [d.id, d.userId])); + + const validEnvelopes = envelopes.filter(env => deviceToUser.has(env.recipientDeviceId)).map(env => ({ + messageId, + recipientDeviceId: env.recipientDeviceId, + recipientUserId: deviceToUser.get(env.recipientDeviceId)!, + ciphertext: env.ciphertext + })); + + if (validEnvelopes.length > 0) { + await db.insert(messageEnvelopes).values(validEnvelopes); + } + } + + // Emit acknowledgment to sender + if (message) { + socket.emit('message_ack', { messageId, sequenceNumber: message.sequenceNumber }); + } + io.to(conversationId).emit('new_message', message); const members = await db.query.conversationMembers.findMany({ @@ -116,6 +171,28 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void }); }); + // ── delete_message ───────────────────────────────────────────────────────── + // Payload: { messageId: string } + // Sender retraction + socket.on('delete_message', async (payload: { messageId: string }) => { + const { messageId } = payload; + if (!messageId) return; + + const message = await db.query.messages.findFirst({ + where: eq(messages.id, messageId), + }); + + if (!message || message.senderId !== userId) { + socket.emit('error', { event: 'delete_message', message: 'Message not found or not sender' }); + return; + } + + await db.update(messages).set({ deletedAt: new Date(), ciphertext: null }).where(eq(messages.id, messageId)); + await db.delete(messageEnvelopes).where(eq(messageEnvelopes.messageId, messageId)); + + io.to(message.conversationId).emit('message_deleted', { messageId }); + }); + // ── message_read ─────────────────────────────────────────────────────────── // Payload: { conversationId: string; lastReadMessageId: string } // Persists the caller's read position and broadcasts to the room. @@ -317,7 +394,8 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void .values({ conversationId, senderId: ASSISTANT_USER_ID, - content: data.reply, + contentType: 'text/plain', + ciphertext: data.reply, }) .returning(); diff --git a/apps/backend/vitest.config.d.ts b/apps/backend/vitest.config.d.ts new file mode 100644 index 0000000..2b17c25 --- /dev/null +++ b/apps/backend/vitest.config.d.ts @@ -0,0 +1,3 @@ +declare const _default: import("vite").UserConfig; +export default _default; +//# sourceMappingURL=vitest.config.d.ts.map \ No newline at end of file diff --git a/apps/backend/vitest.config.d.ts.map b/apps/backend/vitest.config.d.ts.map new file mode 100644 index 0000000..062d697 --- /dev/null +++ b/apps/backend/vitest.config.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"vitest.config.d.ts","sourceRoot":"","sources":["vitest.config.ts"],"names":[],"mappings":";AAEA,wBAKG"} \ No newline at end of file diff --git a/apps/backend/vitest.config.js b/apps/backend/vitest.config.js new file mode 100644 index 0000000..771b453 --- /dev/null +++ b/apps/backend/vitest.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; +export default defineConfig({ + test: { + environment: 'node', + setupFiles: ['./src/__tests__/setup.ts'], + }, +}); +//# sourceMappingURL=vitest.config.js.map \ No newline at end of file diff --git a/apps/backend/vitest.config.js.map b/apps/backend/vitest.config.js.map new file mode 100644 index 0000000..0958da5 --- /dev/null +++ b/apps/backend/vitest.config.js.map @@ -0,0 +1 @@ +{"version":3,"file":"vitest.config.js","sourceRoot":"","sources":["vitest.config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAE7C,eAAe,YAAY,CAAC;IAC1B,IAAI,EAAE;QACJ,WAAW,EAAE,MAAM;QACnB,UAAU,EAAE,CAAC,0BAA0B,CAAC;KACzC;CACF,CAAC,CAAC"} \ No newline at end of file From 07fd7b4419701899ab0f1fa0cc227ce3f6201703 Mon Sep 17 00:00:00 2001 From: testersweb0-bug Date: Sun, 28 Jun 2026 18:01:56 +0100 Subject: [PATCH 2/5] style: fix prettier formatting issues --- .../src/__tests__/conversations.cache.test.ts | 5 +- .../__tests__/conversations.routes.test.ts | 5 +- apps/backend/src/db/schema.ts | 45 +++-- apps/backend/src/lib/messages.ts | 5 +- apps/backend/src/routes/conversations.ts | 27 ++- apps/backend/src/routes/messages.ts | 2 +- apps/backend/src/socket/messaging.ts | 175 ++++++++++-------- 7 files changed, 154 insertions(+), 110 deletions(-) diff --git a/apps/backend/src/__tests__/conversations.cache.test.ts b/apps/backend/src/__tests__/conversations.cache.test.ts index 050d891..fdc93b6 100644 --- a/apps/backend/src/__tests__/conversations.cache.test.ts +++ b/apps/backend/src/__tests__/conversations.cache.test.ts @@ -95,7 +95,10 @@ const TEST_USER_ID = 'user-test-123'; vi.mock('../middleware/auth.js', () => ({ requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { auth: { userId: string; deviceId: string } }).auth = { userId: TEST_USER_ID, deviceId: 'device-test-123' }; + (req as express.Request & { auth: { userId: string; deviceId: string } }).auth = { + userId: TEST_USER_ID, + deviceId: 'device-test-123', + }; next(); }, })); diff --git a/apps/backend/src/__tests__/conversations.routes.test.ts b/apps/backend/src/__tests__/conversations.routes.test.ts index 6307fe7..20f1202 100644 --- a/apps/backend/src/__tests__/conversations.routes.test.ts +++ b/apps/backend/src/__tests__/conversations.routes.test.ts @@ -74,7 +74,10 @@ vi.mock('drizzle-orm', () => ({ vi.mock('../middleware/auth.js', () => ({ requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { auth: { userId: string; deviceId: string } }).auth = { userId: 'user-1', deviceId: 'device-1' }; + (req as express.Request & { auth: { userId: string; deviceId: string } }).auth = { + userId: 'user-1', + deviceId: 'device-1', + }; next(); }, })); diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index a118ed5..b3af12b 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -58,24 +58,23 @@ 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' }), - senderDeviceId: uuid('sender_device_id').references(() => userDevices.id, { onDelete: 'set null' }), - contentType: text('content_type').notNull().default('text/plain'), - sequenceNumber: serial('sequence_number'), - 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' }), + senderDeviceId: uuid('sender_device_id').references(() => userDevices.id, { + onDelete: 'set null', + }), + contentType: text('content_type').notNull().default('text/plain'), + sequenceNumber: serial('sequence_number'), + ciphertext: text('ciphertext'), + createdAt: timestamp('created_at').notNull().defaultNow(), + deletedAt: timestamp('deleted_at'), +}); export const messageEnvelopes = pgTable( 'message_envelopes', @@ -287,13 +286,19 @@ export const messagesRelations = relations(messages, ({ one, many }) => ({ references: [conversations.id], }), sender: one(users, { fields: [messages.senderId], references: [users.id] }), - senderDevice: one(userDevices, { fields: [messages.senderDeviceId], references: [userDevices.id] }), + senderDevice: one(userDevices, { + fields: [messages.senderDeviceId], + references: [userDevices.id], + }), envelopes: many(messageEnvelopes), })); export const messageEnvelopesRelations = relations(messageEnvelopes, ({ one }) => ({ message: one(messages, { fields: [messageEnvelopes.messageId], references: [messages.id] }), - recipientDevice: one(userDevices, { fields: [messageEnvelopes.recipientDeviceId], references: [userDevices.id] }), + recipientDevice: one(userDevices, { + fields: [messageEnvelopes.recipientDeviceId], + references: [userDevices.id], + }), recipientUser: one(users, { fields: [messageEnvelopes.recipientUserId], references: [users.id] }), })); diff --git a/apps/backend/src/lib/messages.ts b/apps/backend/src/lib/messages.ts index 30dc098..37601c2 100644 --- a/apps/backend/src/lib/messages.ts +++ b/apps/backend/src/lib/messages.ts @@ -13,7 +13,10 @@ type MessageLike = { export function serializeMessage( message: T, -): Omit & { ciphertext: string | null; unavailable?: boolean } { +): Omit & { + ciphertext: string | null; + unavailable?: boolean; +} { const { deletedAt, envelopes, ciphertext: baseCiphertext, ...rest } = message; if (deletedAt) { diff --git a/apps/backend/src/routes/conversations.ts b/apps/backend/src/routes/conversations.ts index 6c1bb31..8fd2d4b 100644 --- a/apps/backend/src/routes/conversations.ts +++ b/apps/backend/src/routes/conversations.ts @@ -2,7 +2,13 @@ import { Router } from 'express'; import type { IRouter } from 'express'; import { asc, and, count, desc, eq, lt, sql, ne } from 'drizzle-orm'; import { db } from '../db/index.js'; -import { conversationMembers, conversations, messages, tokenTransfers, messageEnvelopes } from '../db/schema.js'; +import { + conversationMembers, + conversations, + messages, + tokenTransfers, + messageEnvelopes, +} from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { redis, CONV_CACHE_TTL, convCacheKey } from '../lib/redis.js'; import { invalidateConversationCaches } from '../lib/conversationCache.js'; @@ -28,12 +34,12 @@ const getConversationRelations = (deviceId: string) => ({ messages: { orderBy: desc(messages.createdAt), limit: 1, - with: { + with: { sender: { columns: { id: true, username: true, avatarUrl: true } }, envelopes: { where: eq(messageEnvelopes.recipientDeviceId, deviceId), limit: 1, - } + }, }, }, }); @@ -46,7 +52,9 @@ type ConversationPayload = { function serializeConversation(conversation: T): T { return { ...conversation, - messages: (conversation.messages ?? []).map((message) => serializeMessage(message as any)) as any, + messages: (conversation.messages ?? []).map((message) => + serializeMessage(message as any), + ) as any, }; } @@ -101,7 +109,12 @@ conversationsRouter.get('/', async (req: AuthRequest, res) => { with: { conversation: getConversationRelations(req.auth!.deviceId) as never, }, - })) as unknown as Array<{ conversationId: string; isMuted: boolean; isArchived: boolean; conversation: ConversationPayload }>; + })) as unknown as Array<{ + conversationId: string; + isMuted: boolean; + isArchived: boolean; + conversation: ConversationPayload; + }>; // Single subquery for message counts — no N+1 const conversationIds = memberships.map((m) => m.conversationId); @@ -477,12 +490,12 @@ conversationsRouter.get('/:id/messages', async (req: AuthRequest, res) => { : eq(messages.conversationId, conversationId), orderBy: desc(messages.createdAt), limit: limit + 1, - with: { + with: { sender: { columns: { id: true, username: true, avatarUrl: true } }, envelopes: { where: eq(messageEnvelopes.recipientDeviceId, req.auth!.deviceId), limit: 1, - } + }, }, }); diff --git a/apps/backend/src/routes/messages.ts b/apps/backend/src/routes/messages.ts index 5bea9bc..32e1ba3 100644 --- a/apps/backend/src/routes/messages.ts +++ b/apps/backend/src/routes/messages.ts @@ -38,7 +38,7 @@ messagesRouter.delete('/:id', async (req: AuthRequest, res) => { .update(messages) .set({ deletedAt: new Date(), ciphertext: null }) .where(and(eq(messages.id, messageId), eq(messages.senderId, userId))); - + await db.delete(messageEnvelopes).where(eq(messageEnvelopes.messageId, messageId)); getSocketServer()?.to(message.conversationId).emit('message_deleted', { diff --git a/apps/backend/src/socket/messaging.ts b/apps/backend/src/socket/messaging.ts index a6cecec..c53c959 100644 --- a/apps/backend/src/socket/messaging.ts +++ b/apps/backend/src/socket/messaging.ts @@ -1,7 +1,13 @@ import type { Server } from 'socket.io'; import { and, eq, lt, desc, sql, inArray } from 'drizzle-orm'; import { db } from '../db/index.js'; -import { conversations, conversationMembers, messages, messageEnvelopes, userDevices } from '../db/schema.js'; +import { + conversations, + conversationMembers, + messages, + messageEnvelopes, + userDevices, +} from '../db/schema.js'; import type { AuthSocket } from '../middleware/socketAuth.js'; import { invalidateConversationCaches } from '../lib/conversationCache.js'; import { serializeMessage } from '../lib/messages.js'; @@ -37,95 +43,103 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void // ── send_message ─────────────────────────────────────────────────────────── // Payload: { conversationId, messageId, contentType, ciphertext, envelopes } // Persists the message and broadcasts it to all room members. - socket.on('send_message', async (payload: { - conversationId: string; - messageId: string; - contentType?: string; - ciphertext?: string; - envelopes?: Array<{ recipientDeviceId: string; ciphertext: string }>; - }) => { - const { conversationId, messageId, contentType, ciphertext, envelopes } = payload; - const deviceId = socket.auth!.deviceId; - - if (!messageId) { - socket.emit('error', { event: 'send_message', message: 'messageId is required' }); - return; - } - - if (!ciphertext?.trim() && (!envelopes || envelopes.length === 0)) { - socket.emit('error', { event: 'send_message', message: 'Message content is empty' }); - return; - } + socket.on( + 'send_message', + async (payload: { + conversationId: string; + messageId: string; + contentType?: string; + ciphertext?: string; + envelopes?: Array<{ recipientDeviceId: string; ciphertext: string }>; + }) => { + const { conversationId, messageId, contentType, ciphertext, envelopes } = payload; + const deviceId = socket.auth!.deviceId; + + if (!messageId) { + socket.emit('error', { event: 'send_message', message: 'messageId is required' }); + return; + } - const membership = await db.query.conversationMembers.findFirst({ - where: and( - eq(conversationMembers.conversationId, conversationId), - eq(conversationMembers.userId, userId), - ), - }); + if (!ciphertext?.trim() && (!envelopes || envelopes.length === 0)) { + socket.emit('error', { event: 'send_message', message: 'Message content is empty' }); + return; + } - if (!membership) { - socket.emit('error', { event: 'send_message', message: 'Not a member of this conversation' }); - return; - } + const membership = await db.query.conversationMembers.findFirst({ + where: and( + eq(conversationMembers.conversationId, conversationId), + eq(conversationMembers.userId, userId), + ), + }); - // Idempotency check - const existing = await db.query.messages.findFirst({ - where: eq(messages.id, messageId), - columns: { sequenceNumber: true }, - }); - - if (existing) { - socket.emit('message_ack', { messageId, sequenceNumber: existing.sequenceNumber }); - return; - } + if (!membership) { + socket.emit('error', { + event: 'send_message', + message: 'Not a member of this conversation', + }); + return; + } - const [message] = await db - .insert(messages) - .values({ - id: messageId, - conversationId, - senderId: userId, - senderDeviceId: deviceId, - contentType: contentType || 'text/plain', - ciphertext: ciphertext || null, - }) - .returning(); - - if (envelopes && envelopes.length > 0) { - const deviceIds = envelopes.map(e => e.recipientDeviceId); - const devicesList = await db.query.userDevices.findMany({ - where: inArray(userDevices.id, deviceIds), - columns: { id: true, userId: true } + // Idempotency check + const existing = await db.query.messages.findFirst({ + where: eq(messages.id, messageId), + columns: { sequenceNumber: true }, }); - const deviceToUser = new Map(devicesList.map(d => [d.id, d.userId])); - const validEnvelopes = envelopes.filter(env => deviceToUser.has(env.recipientDeviceId)).map(env => ({ - messageId, - recipientDeviceId: env.recipientDeviceId, - recipientUserId: deviceToUser.get(env.recipientDeviceId)!, - ciphertext: env.ciphertext - })); + if (existing) { + socket.emit('message_ack', { messageId, sequenceNumber: existing.sequenceNumber }); + return; + } + + const [message] = await db + .insert(messages) + .values({ + id: messageId, + conversationId, + senderId: userId, + senderDeviceId: deviceId, + contentType: contentType || 'text/plain', + ciphertext: ciphertext || null, + }) + .returning(); - if (validEnvelopes.length > 0) { - await db.insert(messageEnvelopes).values(validEnvelopes); + if (envelopes && envelopes.length > 0) { + const deviceIds = envelopes.map((e) => e.recipientDeviceId); + const devicesList = await db.query.userDevices.findMany({ + where: inArray(userDevices.id, deviceIds), + columns: { id: true, userId: true }, + }); + const deviceToUser = new Map(devicesList.map((d) => [d.id, d.userId])); + + const validEnvelopes = envelopes + .filter((env) => deviceToUser.has(env.recipientDeviceId)) + .map((env) => ({ + messageId, + recipientDeviceId: env.recipientDeviceId, + recipientUserId: deviceToUser.get(env.recipientDeviceId)!, + ciphertext: env.ciphertext, + })); + + if (validEnvelopes.length > 0) { + await db.insert(messageEnvelopes).values(validEnvelopes); + } } - } - // Emit acknowledgment to sender - if (message) { - socket.emit('message_ack', { messageId, sequenceNumber: message.sequenceNumber }); - } + // Emit acknowledgment to sender + if (message) { + socket.emit('message_ack', { messageId, sequenceNumber: message.sequenceNumber }); + } - io.to(conversationId).emit('new_message', message); + io.to(conversationId).emit('new_message', message); - const members = await db.query.conversationMembers.findMany({ - where: eq(conversationMembers.conversationId, conversationId), - columns: { userId: true }, - }); + const members = await db.query.conversationMembers.findMany({ + where: eq(conversationMembers.conversationId, conversationId), + columns: { userId: true }, + }); - await invalidateConversationCaches(members.map((member) => member.userId)); - }); + await invalidateConversationCaches(members.map((member) => member.userId)); + }, + ); // ── message_history ──────────────────────────────────────────────────────── // Payload: { conversationId: string; before?: string } (before = message id cursor) @@ -187,7 +201,10 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void return; } - await db.update(messages).set({ deletedAt: new Date(), ciphertext: null }).where(eq(messages.id, messageId)); + await db + .update(messages) + .set({ deletedAt: new Date(), ciphertext: null }) + .where(eq(messages.id, messageId)); await db.delete(messageEnvelopes).where(eq(messageEnvelopes.messageId, messageId)); io.to(message.conversationId).emit('message_deleted', { messageId }); From ae12e5750f98be04b417a5f1735ee282b162969e Mon Sep 17 00:00:00 2001 From: testersweb0-bug Date: Sun, 28 Jun 2026 18:03:54 +0100 Subject: [PATCH 3/5] fix: resolve duplicate Router import conflict in treasury.ts --- apps/backend/src/routes/treasury.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/backend/src/routes/treasury.ts b/apps/backend/src/routes/treasury.ts index a0cfc7b..12f443b 100644 --- a/apps/backend/src/routes/treasury.ts +++ b/apps/backend/src/routes/treasury.ts @@ -1,6 +1,4 @@ -import { Router } from 'express'; -import type { IRouter } from 'express'; -import { Router, type Router as RouterType } from 'express'; +import { Router, type IRouter, type Router as RouterType } from 'express'; import { z } from 'zod'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; From 72d1bb7ae608da0812493fe4edfce764086e51d3 Mon Sep 17 00:00:00 2001 From: testersweb0-bug Date: Sun, 28 Jun 2026 18:12:40 +0100 Subject: [PATCH 4/5] chore: fix unused variables and remove build artifacts from src --- apps/backend/src/db/schema.ts | 1 - apps/backend/src/routes/conversations.ts | 1 - apps/backend/src/routes/treasury.ts | 6 ++---- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 9087ab8..9823e8d 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -9,7 +9,6 @@ import { integer, serial, uniqueIndex, - bigint, } from 'drizzle-orm/pg-core'; import { relations, sql } from 'drizzle-orm'; diff --git a/apps/backend/src/routes/conversations.ts b/apps/backend/src/routes/conversations.ts index 8fd2d4b..f1abc5c 100644 --- a/apps/backend/src/routes/conversations.ts +++ b/apps/backend/src/routes/conversations.ts @@ -20,7 +20,6 @@ export const conversationsRouter: IRouter = Router(); conversationsRouter.use(requireAuth); -const SEARCH_RESULT_LIMIT = 20; const getConversationRelations = (deviceId: string) => ({ members: { diff --git a/apps/backend/src/routes/treasury.ts b/apps/backend/src/routes/treasury.ts index 12f443b..3fdc30f 100644 --- a/apps/backend/src/routes/treasury.ts +++ b/apps/backend/src/routes/treasury.ts @@ -1,10 +1,8 @@ -import { Router, type IRouter, type Router as RouterType } from 'express'; +import { Router, type IRouter } from 'express'; import { z } from 'zod'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; -import { and, desc, eq } from 'drizzle-orm'; -import { db } from '../db/index.js'; -import { conversationMembers, treasuryProposals } from '../db/schema.js'; + export const treasuryRouter: IRouter = Router(); From 57c65b37257f36a8a26468465164ed277dd2e48a Mon Sep 17 00:00:00 2001 From: testersweb0-bug Date: Sun, 28 Jun 2026 18:13:03 +0100 Subject: [PATCH 5/5] style: fix formatting after manual lint resolution --- apps/backend/src/routes/conversations.ts | 1 - apps/backend/src/routes/treasury.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/apps/backend/src/routes/conversations.ts b/apps/backend/src/routes/conversations.ts index f1abc5c..673385b 100644 --- a/apps/backend/src/routes/conversations.ts +++ b/apps/backend/src/routes/conversations.ts @@ -20,7 +20,6 @@ export const conversationsRouter: IRouter = Router(); conversationsRouter.use(requireAuth); - const getConversationRelations = (deviceId: string) => ({ members: { with: { diff --git a/apps/backend/src/routes/treasury.ts b/apps/backend/src/routes/treasury.ts index 3fdc30f..f11d342 100644 --- a/apps/backend/src/routes/treasury.ts +++ b/apps/backend/src/routes/treasury.ts @@ -3,7 +3,6 @@ import { z } from 'zod'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; - export const treasuryRouter: IRouter = Router(); treasuryRouter.use(requireAuth);