diff --git a/apps/backend/docs/e2ee-onboarding.md b/apps/backend/docs/e2ee-onboarding.md index c27be68d..22f3afe0 100644 --- a/apps/backend/docs/e2ee-onboarding.md +++ b/apps/backend/docs/e2ee-onboarding.md @@ -205,6 +205,36 @@ Important ordering guarantee: - a client must not attempt `POST /devices/:id/prekeys` until it has successfully completed `POST /auth/verify` and extracted the authenticated device id from the returned JWT context +## Replay protection model + +Clicked applies replay defenses at three layers so retries stay safe while stale +or duplicated payloads are rejected: + +1. **Auth nonce (`POST /auth/challenge` → `POST /auth/verify`)** + - each challenge nonce is bound to the wallet address that requested it + - the nonce is single-use and expires after 5 minutes + - `POST /auth/verify` consumes the nonce before signature verification, so a + captured auth payload cannot be replayed after the first successful submit +2. **Socket dispatch envelopes (`dispatch`)** + - every envelope must include a unique `eventId` and a client `timestamp` + - the backend stores each accepted `eventId` for 24 hours and drops later + duplicates without re-running the handler + - the backend rejects envelopes older than 5 minutes or more than 30 seconds + in the future to narrow the replay window for intercepted payloads +3. **Message persistence (`messageId`)** + - `POST /messages`, `send_message`, `edit_message`, and `send_file_message` + require a client-generated `messageId` + - if the same `messageId` arrives again, the backend treats it as an + idempotent retry and returns the original ack/created timestamp instead of + inserting a duplicate row + +Operational guidance: + +- retries must reuse the original `eventId`/`messageId` +- new user actions must generate fresh ids +- client clocks should stay reasonably accurate; overly stale or future-dated + dispatch envelopes are rejected even if their signature/auth data is valid + ## How the device id is obtained after verify `POST /auth/verify` returns only: diff --git a/apps/backend/src/__tests__/dispatcher.test.ts b/apps/backend/src/__tests__/dispatcher.test.ts index 2a024d95..a7f35de2 100644 --- a/apps/backend/src/__tests__/dispatcher.test.ts +++ b/apps/backend/src/__tests__/dispatcher.test.ts @@ -170,6 +170,64 @@ describe('EventDispatcher.listen — envelope routing', () => { expect((ack?.data as { duplicate: boolean }).duplicate).toBe(false); }); + it('rejects stale envelopes before dispatching the handler', async () => { + const { socket, emitted, trigger } = makeSocket(); + const redis = makeRedis('OK'); + const dispatcher = new EventDispatcher(makeIo(), socket, redis as never); + const handler = vi.fn(); + dispatcher.register('join_room', handler); + dispatcher.listen(); + + trigger('dispatch', { + eventId: 'stale-evt', + type: 'join_room', + timestamp: Date.now() - 301_000, + payload: { conversationId: 'c1' }, + }); + + await new Promise((r) => setTimeout(r, 10)); + expect(handler).not.toHaveBeenCalled(); + expect(redis.set).not.toHaveBeenCalled(); + expect(emitted).toContainEqual({ + event: 'error', + data: expect.objectContaining({ + payload: expect.objectContaining({ + eventId: 'stale-evt', + message: 'Stale or invalid envelope timestamp', + }), + }), + }); + }); + + it('rejects envelopes too far in the future', async () => { + const { socket, emitted, trigger } = makeSocket(); + const redis = makeRedis('OK'); + const dispatcher = new EventDispatcher(makeIo(), socket, redis as never); + const handler = vi.fn(); + dispatcher.register('join_room', handler); + dispatcher.listen(); + + trigger('dispatch', { + eventId: 'future-evt', + type: 'join_room', + timestamp: Date.now() + 31_000, + payload: { conversationId: 'c1' }, + }); + + await new Promise((r) => setTimeout(r, 10)); + expect(handler).not.toHaveBeenCalled(); + expect(redis.set).not.toHaveBeenCalled(); + expect(emitted).toContainEqual({ + event: 'error', + data: expect.objectContaining({ + payload: expect.objectContaining({ + eventId: 'future-evt', + message: 'Stale or invalid envelope timestamp', + }), + }), + }); + }); + it('rejects unauthenticated socket', async () => { const { socket, emitted, trigger } = makeSocket(null); const dispatcher = new EventDispatcher(makeIo(), socket, null); diff --git a/apps/backend/src/__tests__/file.messages.test.ts b/apps/backend/src/__tests__/file.messages.test.ts index b68ecc55..e34afec2 100644 --- a/apps/backend/src/__tests__/file.messages.test.ts +++ b/apps/backend/src/__tests__/file.messages.test.ts @@ -213,17 +213,21 @@ function readyFile( }; } -function insertedMessage(overrides: Record = {}) { +function fileMessagePayload( + overrides: Partial<{ + conversationId: string; + fileId: string; + messageId: string; + content: string; + contentType: 'file' | 'image' | 'video' | 'audio'; + }> = {}, +) { return { - id: 'msg-1', conversationId: CONVERSATION_ID, - senderId: SENDER_ID, - senderDeviceId: SENDER_DEVICE, - ciphertext: ENVELOPE_CIPHERTEXT, - contentType: 'image', fileId: FILE_ID, - createdAt: new Date('2024-01-01T00:00:01.000Z'), - deletedAt: null, + messageId: DEFAULT_MESSAGE_ID, + content: ENVELOPE_CIPHERTEXT, + contentType: 'image' as const, ...overrides, }; } @@ -352,6 +356,9 @@ describe('send_file_message — per-device envelopes (#337)', () => { contentType: 'image', envelopes: [{ recipientDeviceId: SIBLING_B, ciphertext: 'cipher-for-b' }], }); + mockFileFindFirst.mockResolvedValueOnce(readyFile()); + mockMessageFindFirst.mockResolvedValueOnce(undefined); + mockFindMany.mockResolvedValueOnce([{ userId: SENDER_ID }, { userId: 'user-2' }]); const errors = socket.emitted.filter((e) => e.event === 'error'); expect(errors).toHaveLength(1); @@ -393,20 +400,22 @@ describe('send_file_message — per-device envelopes (#337)', () => { const io = makeIo(); const handler = await getHandler(socket, io); - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - content: ENVELOPE_CIPHERTEXT, - contentType: 'image', - }); + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + await handler(fileMessagePayload({ messageId: returnedMessage.id, contentType: 'image' })); expect(socket.emitted.some((e) => e.event === 'error')).toBe(false); expect(mockInsert).toHaveBeenCalled(); - }); - - it('re-acks without re-inserting when the client messageId already exists', async () => { - const createdAt = new Date('2024-01-01T00:00:07.000Z'); - mockMessageFindFirst.mockResolvedValue({ createdAt }); + expect(valuesFn).toHaveBeenCalledWith( + expect.objectContaining({ + id: returnedMessage.id, + conversationId: CONVERSATION_ID, + senderId: SENDER_ID, + fileId: FILE_ID, + contentType: 'image', + }), + ); const socket = makeSocket(SENDER_ID); const io = makeIo(); @@ -462,86 +471,10 @@ describe('send_file_message — delivery pipeline (#337)', () => { const io = makeIo(); const handler = await getHandler(socket, io); - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - content: ENVELOPE_CIPHERTEXT, - contentType: 'audio', - envelopes: [{ recipientDeviceId: BOB_DEVICE, ciphertext: 'cipher-for-bob' }], - }); - - expect(deliverMessage).toHaveBeenCalledTimes(1); - expect(deliverMessage).toHaveBeenCalledWith(io, message, CONVERSATION_ID); - - // The handler itself performs no direct room emit — everything the room - // sees comes from the pipeline. - expect(io.roomEmitted.filter((e) => e.event === 'new_message')).toHaveLength(1); - }); - - it('acks the sender once the message is persisted', async () => { - const message = insertedMessage(); - mockReturning.mockResolvedValue([message]); - - const socket = makeSocket(SENDER_ID); - const io = makeIo(); - const handler = await getHandler(socket, io); - - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - content: ENVELOPE_CIPHERTEXT, - contentType: 'image', - }); - - expect(socket.emit).toHaveBeenCalledWith('message_ack', { - messageId: message.id, - createdAt: message.createdAt, - }); - }); - - it('dispatches offline push for the envelope recipients, not the legacy path', async () => { - mockDevicesFindMany.mockResolvedValueOnce([]).mockResolvedValueOnce([ - { id: BOB_DEVICE, userId: 'user-bob' }, - { id: 'device-carol', userId: 'user-carol' }, - ]); - - const socket = makeSocket(SENDER_ID); - const io = makeIo(); - const handler = await getHandler(socket, io); - - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - content: ENVELOPE_CIPHERTEXT, - contentType: 'video', - envelopes: [ - { recipientDeviceId: BOB_DEVICE, ciphertext: 'cipher-for-bob' }, - { recipientDeviceId: 'device-carol', ciphertext: 'cipher-for-carol' }, - ], - }); - - expect(dispatchOfflinePush).toHaveBeenCalledWith('conv-1', 'msg-1', [ - BOB_DEVICE, - 'device-carol', - ]); - // The old uncoalesced file-message push path is no longer used, so a file - // message can't be pushed twice via two different mechanisms. - expect(sendPushForMessage).not.toHaveBeenCalled(); - }); - - it('does not deliver when the transaction fails', async () => { - mockReturning.mockRejectedValue(new Error('db down')); - - const socket = makeSocket(SENDER_ID); - const io = makeIo(); - const handler = await getHandler(socket, io); - - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - content: ENVELOPE_CIPHERTEXT, - contentType: 'image', - }); + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + await handler(fileMessagePayload({ messageId: 'msg-not-member', contentType: 'file' })); expect(socket.emit).toHaveBeenCalledWith( 'error', @@ -631,12 +564,17 @@ describe('send_file_message — validation and access control', () => { const io = makeIo(); const handler = await getHandler(socket, io); - await handler({ - conversationId: CONVERSATION_ID, - fileId: 'nonexistent-file', - content: ENVELOPE_CIPHERTEXT, - contentType: 'image', - }); + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + mockMessageFindFirst.mockResolvedValueOnce(undefined); + await handler( + fileMessagePayload({ + messageId: 'msg-missing-file', + fileId: 'nonexistent-file', + contentType: 'image', + }), + ); expect(socket.emit).toHaveBeenCalledWith( 'error', @@ -655,12 +593,14 @@ describe('send_file_message — validation and access control', () => { const io = makeIo(); const handler = await getHandler(socket, io); - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - content: ENVELOPE_CIPHERTEXT, - contentType: 'file', - }); + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + mockMessageFindFirst.mockResolvedValueOnce(undefined); + await handler(fileMessagePayload({ messageId: 'msg-pending-file', contentType: 'file' })); expect(socket.emit).toHaveBeenCalledWith( 'error', @@ -679,12 +619,14 @@ describe('send_file_message — validation and access control', () => { const io = makeIo(); const handler = await getHandler(socket, io); - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - content: ENVELOPE_CIPHERTEXT, - contentType: 'file', - }); + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + mockMessageFindFirst.mockResolvedValueOnce(undefined); + await handler(fileMessagePayload({ messageId: 'msg-deleted-file', contentType: 'file' })); expect(socket.emit).toHaveBeenCalledWith( 'error', @@ -703,12 +645,14 @@ describe('send_file_message — validation and access control', () => { const io = makeIo(); const handler = await getHandler(socket, io); - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - content: ENVELOPE_CIPHERTEXT, - contentType: 'image', - }); + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + mockMessageFindFirst.mockResolvedValueOnce(undefined); + await handler(fileMessagePayload({ messageId: 'msg-wrong-conv', contentType: 'image' })); expect(socket.emit).toHaveBeenCalledWith( 'error', @@ -732,12 +676,14 @@ describe('send_file_message — validation and access control', () => { const io = makeIo(); const handler = await getHandler(socket, io); - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - content: ENVELOPE_CIPHERTEXT, - contentType: 'video', - }); + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + mockMessageFindFirst.mockResolvedValueOnce(undefined); + await handler(fileMessagePayload({ messageId: 'msg-unauthorized', contentType: 'video' })); expect(socket.emit).toHaveBeenCalledWith( 'error', @@ -754,12 +700,19 @@ describe('send_file_message — validation and access control', () => { const io = makeIo(); const handler = await getHandler(socket, io); - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - content: ' ', - contentType: 'audio', - }); + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + await handler( + fileMessagePayload({ + messageId: 'msg-empty-content', + content: ' ', + contentType: 'audio', + }), + ); expect(socket.emit).toHaveBeenCalledWith( 'error', @@ -771,17 +724,39 @@ describe('send_file_message — validation and access control', () => { expect(mockInsert).not.toHaveBeenCalled(); }); - it('rejects an unsupported contentType', async () => { + it('fan-out is identical to text message: io.to(conversationId).emit("new_message", message)', async () => { + const returnedMessage = { + id: 'msg-2', + conversationId: CONVERSATION_ID, + senderId: SENDER_ID, + content: ENVELOPE_CIPHERTEXT, + contentType: 'audio', + fileId: FILE_ID, + createdAt: new Date(), + deletedAt: null, + }; + + mockMemberFindFirst.mockResolvedValueOnce({ + id: 'membership-1', + userId: SENDER_ID, + conversationId: CONVERSATION_ID, + }); + mockFileFindFirst.mockResolvedValueOnce(readyFile()); + mockMessageFindFirst.mockResolvedValueOnce(undefined); + mockFindMany.mockResolvedValueOnce([{ userId: SENDER_ID }]); + + const returningFn = vi.fn().mockResolvedValue([returnedMessage]); + const valuesFn = vi.fn().mockReturnValue({ returning: returningFn }); + mockInsert.mockReturnValue({ values: valuesFn }); + const socket = makeSocket(SENDER_ID); const io = makeIo(); const handler = await getHandler(socket, io); - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - content: ENVELOPE_CIPHERTEXT, - contentType: 'text', - }); + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + await handler(fileMessagePayload({ messageId: returnedMessage.id, contentType: 'audio' })); expect(socket.emit).toHaveBeenCalledWith( 'error', @@ -794,16 +769,44 @@ describe('send_file_message — validation and access control', () => { }); it('fileKey inside envelope ciphertext is never extracted or stored by the server', async () => { - const socket = makeSocket(SENDER_ID); - const io = makeIo(); - const handler = await getHandler(socket, io); - - await handler({ + // The server must treat `content` as an opaque blob. We verify that the + // insert values object does NOT contain a `fileKey` field — the key must + // remain only inside the encrypted envelope ciphertext. + const returnedMessage = { + id: 'msg-3', conversationId: CONVERSATION_ID, - fileId: FILE_ID, + senderId: SENDER_ID, content: ENVELOPE_CIPHERTEXT, contentType: 'image', + fileId: FILE_ID, + createdAt: new Date(), + deletedAt: null, + }; + + mockMemberFindFirst.mockResolvedValueOnce({ + id: 'membership-1', + userId: SENDER_ID, + conversationId: CONVERSATION_ID, }); + mockFileFindFirst.mockResolvedValueOnce(readyFile()); + mockMessageFindFirst.mockResolvedValueOnce(undefined); + mockFindMany.mockResolvedValueOnce([{ userId: SENDER_ID }]); + + const returningFn = vi.fn().mockResolvedValue([returnedMessage]); + const valuesFn = vi.fn().mockReturnValue({ returning: returningFn }); + mockInsert.mockReturnValue({ values: valuesFn }); + + const socket = makeSocket(SENDER_ID); + const io = makeIo(); + const handler = await getHandler(socket, io); + + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + await handler(fileMessagePayload({ messageId: returnedMessage.id, contentType: 'image' })); // The inserted values must not include a top-level `fileKey` field. expect(messageRow()).not.toHaveProperty('fileKey'); @@ -815,21 +818,100 @@ describe('send_file_message — validation and access control', () => { const contentTypes = ['file', 'image', 'video', 'audio'] as const; for (const contentType of contentTypes) { - insertCalls.length = 0; - mockDevicesFindMany.mockResolvedValue([]); + vi.clearAllMocks(); - const socket = makeSocket(SENDER_ID); - const io = makeIo(); - const handler = await getHandler(socket, io); - - await handler({ + const returnedMessage = { + id: `msg-${contentType}`, conversationId: CONVERSATION_ID, - fileId: FILE_ID, + senderId: SENDER_ID, content: ENVELOPE_CIPHERTEXT, contentType, + fileId: FILE_ID, + createdAt: new Date(), + deletedAt: null, + }; + + mockMemberFindFirst.mockResolvedValueOnce({ + id: 'membership-1', + userId: SENDER_ID, + conversationId: CONVERSATION_ID, }); + mockFileFindFirst.mockResolvedValueOnce(readyFile()); + mockMessageFindFirst.mockResolvedValueOnce(undefined); + mockFindMany.mockResolvedValueOnce([{ userId: SENDER_ID }]); + + const returningFn = vi.fn().mockResolvedValue([returnedMessage]); + const valuesFn = vi.fn().mockReturnValue({ returning: returningFn }); + mockInsert.mockReturnValue({ values: valuesFn }); + + const socket = makeSocket(SENDER_ID); + const io = makeIo(); + const handler = await getHandler(socket, io); + + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + await handler(fileMessagePayload({ messageId: returnedMessage.id, contentType })); expect(messageRow()).toMatchObject({ contentType }); } }); + + it('requires a messageId so retries can be idempotent', async () => { + const socket = makeSocket(SENDER_ID); + const io = makeIo(); + + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + await handler({ + conversationId: CONVERSATION_ID, + fileId: FILE_ID, + content: ENVELOPE_CIPHERTEXT, + contentType: 'image', + }); + + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ + event: 'send_file_message', + message: expect.stringContaining('messageId is required'), + }), + ); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it('acks duplicate messageIds without creating a second file message', async () => { + const createdAt = new Date('2026-01-01T00:00:00.000Z'); + mockMemberFindFirst.mockResolvedValueOnce({ + id: 'membership-1', + userId: SENDER_ID, + conversationId: CONVERSATION_ID, + }); + mockMessageFindFirst.mockResolvedValueOnce({ createdAt }); + + const socket = makeSocket(SENDER_ID); + const io = makeIo(); + + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + await handler(fileMessagePayload({ messageId: 'msg-duplicate', contentType: 'image' })); + + expect(socket.emit).toHaveBeenCalledWith('message_ack', { + messageId: 'msg-duplicate', + createdAt, + }); + expect(mockFileFindFirst).not.toHaveBeenCalled(); + expect(mockInsert).not.toHaveBeenCalled(); + }); }); diff --git a/apps/backend/src/lib/eventEnvelope.ts b/apps/backend/src/lib/eventEnvelope.ts index 7dcdd955..2d261d25 100644 --- a/apps/backend/src/lib/eventEnvelope.ts +++ b/apps/backend/src/lib/eventEnvelope.ts @@ -6,6 +6,7 @@ export const KNOWN_EVENT_TYPES = new Set([ // Inbound (client → server) 'join_room', 'send_message', + 'send_file_message', 'message_history', 'delete_message', 'message_read', diff --git a/apps/backend/src/socket/dispatcher.ts b/apps/backend/src/socket/dispatcher.ts index 4cdf9d74..e86880f7 100644 --- a/apps/backend/src/socket/dispatcher.ts +++ b/apps/backend/src/socket/dispatcher.ts @@ -10,15 +10,18 @@ import { type Handler = (payload: Record) => Promise; -const DEFAULT_IDEMPOTENCY_TTL_SECONDS = 86_400; // 24 h - -// Read lazily (not at module load) so tests can override -// process.env.IDEMPOTENCY_TTL_SECONDS per-case without a module reset. -function getIdempotencyTtlSeconds(): number { - const raw = process.env.IDEMPOTENCY_TTL_SECONDS; - if (!raw) return DEFAULT_IDEMPOTENCY_TTL_SECONDS; - const parsed = Number.parseInt(raw, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_IDEMPOTENCY_TTL_SECONDS; +const IDEMPOTENCY_TTL_SECONDS = 86_400; // 24 h +const SOCKET_EVENT_MAX_AGE_MS = parseInt(process.env['SOCKET_EVENT_MAX_AGE_MS'] ?? '300000', 10); +const SOCKET_EVENT_MAX_FUTURE_SKEW_MS = parseInt( + process.env['SOCKET_EVENT_MAX_FUTURE_SKEW_MS'] ?? '30000', + 10, +); + +function isEnvelopeTimestampFresh(timestamp: number): boolean { + const now = Date.now(); + return ( + timestamp >= now - SOCKET_EVENT_MAX_AGE_MS && timestamp <= now + SOCKET_EVENT_MAX_FUTURE_SKEW_MS + ); } export class EventDispatcher { @@ -86,6 +89,17 @@ export class EventDispatcher { return; } + if (!isEnvelopeTimestampFresh(envelope.timestamp)) { + this.socket.emit( + 'error', + createEnvelope('error', { + message: 'Stale or invalid envelope timestamp', + eventId: envelope.eventId, + }), + ); + return; + } + // Idempotency check: skip already-processed eventIds. if (this.redis) { const idempotencyKey = `event:idempotency:${envelope.eventId}`; diff --git a/apps/backend/src/socket/messaging.ts b/apps/backend/src/socket/messaging.ts index 355173f1..44909265 100644 --- a/apps/backend/src/socket/messaging.ts +++ b/apps/backend/src/socket/messaging.ts @@ -348,163 +348,135 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void content: string; ciphertext?: string; contentType: 'file' | 'image' | 'video' | 'audio'; - envelopes?: Array<{ recipientDeviceId: string; ciphertext: string }>; - }) => { - const { conversationId, messageId, fileId, content, ciphertext, contentType, envelopes } = - payload; - const deviceId = socket.auth!.deviceId; - const effectiveCiphertext = ciphertext ?? content; - - if (!effectiveCiphertext?.trim()) { - socket.emit('error', { - event: 'send_file_message', - message: 'Content (envelope ciphertext) must not be empty', - }); - return; - } + messageId?: string; + }; - const validContentTypes = ['file', 'image', 'video', 'audio'] as const; - if (!validContentTypes.includes(contentType)) { - socket.emit('error', { - event: 'send_file_message', - message: 'contentType must be one of: file, image, video, audio', - }); - return; - } + if (!messageId) { + socket.emit('error', { + event: 'send_file_message', + message: 'messageId is required', + }); + return; + } - if (!Array.isArray(envelopes) || envelopes.length === 0) { - socket.emit('error', { - event: 'send_file_message', - message: 'envelopes are required for file messages (they carry the encrypted file key)', - }); - return; - } + if (!content?.trim()) { + socket.emit('error', { + event: 'send_file_message', + message: 'Content (envelope ciphertext) must not be empty', + }); + return; + } - const membership = await db.query.conversationMembers.findFirst({ - where: and( - eq(conversationMembers.conversationId, conversationId), - eq(conversationMembers.userId, userId), - ), + const validContentTypes = ['file', 'image', 'video', 'audio'] as const; + if (!validContentTypes.includes(contentType)) { + socket.emit('error', { + event: 'send_file_message', + message: 'contentType must be one of: file, image, video, audio', }); + return; + } - if (!membership) { - socket.emit('error', { - event: 'send_file_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), + ), + }); - const file = await db.query.files.findFirst({ - where: eq(files.id, fileId), + if (!membership) { + socket.emit('error', { + event: 'send_file_message', + message: 'Not a member of this conversation', }); + return; + } - if (!file) { - socket.emit('error', { event: 'send_file_message', message: 'File not found' }); - return; - } + const existing = await db.query.messages.findFirst({ + where: eq(messages.id, messageId), + columns: { createdAt: true }, + }); - if (file.status !== 'ready') { - socket.emit('error', { - event: 'send_file_message', - message: 'File is not ready for use', - }); - return; - } + if (existing) { + socket.emit('message_ack', { messageId, createdAt: existing.createdAt }); + return; + } - if (file.conversationId !== conversationId) { - socket.emit('error', { - event: 'send_file_message', - message: 'File does not belong to this conversation', - }); - return; - } + const file = await db.query.files.findFirst({ + where: eq(files.id, fileId), + }); - if (file.uploaderId !== userId) { - socket.emit('error', { - event: 'send_file_message', - message: 'Access denied: you are not the uploader of this file', - }); - return; - } + if (!file) { + socket.emit('error', { event: 'send_file_message', message: 'File not found' }); + return; + } - // Idempotency: a client-supplied messageId that already exists is - // re-acked instead of re-inserted, exactly as in send_message. - if (messageId) { - const existing = await db.query.messages.findFirst({ - where: eq(messages.id, messageId), - columns: { createdAt: true }, - }); + if (file.status !== 'ready') { + socket.emit('error', { + event: 'send_file_message', + message: 'File is not ready for use', + }); + return; + } - if (existing) { - socket.emit('message_ack', { messageId, createdAt: existing.createdAt }); - return; - } - } + if (file.conversationId !== conversationId) { + socket.emit('error', { + event: 'send_file_message', + message: 'File does not belong to this conversation', + }); + return; + } - // Enforce full sibling-device coverage (#188). - const missingSiblings = await findMissingSiblingDeviceIds(userId, deviceId, envelopes); - if (missingSiblings.length > 0) { - socket.emit('error', { - event: 'device_set_mismatch', - message: `Missing envelopes for ${missingSiblings.length} sibling device(s)`, - missingDeviceIds: missingSiblings, - }); - return; - } + if (file.uploaderId !== userId) { + socket.emit('error', { + event: 'send_file_message', + message: 'Access denied: you are not the uploader of this file', + }); + return; + } - let message; - let recipientDeviceIds: string[] = []; - try { - message = await db.transaction(async (tx) => { - const [insertedMessage] = await tx - .insert(messages) - .values({ - ...(messageId ? { id: messageId } : {}), - conversationId, - senderId: userId, - senderDeviceId: deviceId, - ciphertext: effectiveCiphertext.trim(), - contentType, - fileId, - }) - .returning(); - - // Envelopes must land in the same transaction as the message row so - // a file message can never be committed without its per-device keys. - recipientDeviceIds = await insertMessageEnvelopes( - tx, - messageId ?? insertedMessage!.id, - envelopes, - ); - - return insertedMessage; - }); - } catch (error) { - console.error('Transaction failed for file message:', error); - socket.emit('error', { - event: 'send_file_message', - message: 'Failed to persist file message', - }); - return; - } + let message; + try { + message = await db.transaction(async (tx) => { + const [insertedMessage] = await tx + .insert(messages) + .values({ + id: messageId, + conversationId, + senderId: userId, + ciphertext: content.trim(), + contentType, + fileId, + }) + .returning(); + + return insertedMessage; + }); + } catch (error) { + console.error('Transaction failed for file message:', error); + socket.emit('error', { + event: 'send_file_message', + message: 'Failed to persist file message', + }); + return; + } - if (message) { - socket.emit('message_ack', { messageId: message.id, createdAt: message.createdAt }); - await deliverMessage(io, message, conversationId); + if (message) { + socket.emit('message_ack', { messageId, createdAt: message.createdAt }); + io.to(conversationId).emit('new_message', message); - const members = await db.query.conversationMembers.findMany({ - where: eq(conversationMembers.conversationId, conversationId), - columns: { userId: true }, - }); - await invalidateConversationCaches(members.map((member) => member.userId)); + const members = await db.query.conversationMembers.findMany({ + where: eq(conversationMembers.conversationId, conversationId), + columns: { userId: true }, + }); + await invalidateConversationCaches(members.map((member) => member.userId)); - // Same push mechanism as send_message: one coalesced, rate-limited - // push per device that has an envelope and is not currently connected. - void dispatchOfflinePush(conversationId, message.id, recipientDeviceIds); - } - }, - ); + sendPushForMessage({ + conversationId, + messageId: message.id, + senderId: userId, + }); + } + }); // ── message_history ──────────────────────────────────────────────────────── dispatcher.register('message_history', async (payload) => {