diff --git a/src/services/messaging/__tests__/message-service.test.ts b/src/services/messaging/__tests__/message-service.test.ts index 72b69416..b529c935 100644 --- a/src/services/messaging/__tests__/message-service.test.ts +++ b/src/services/messaging/__tests__/message-service.test.ts @@ -98,6 +98,13 @@ vi.mock('../offline-queue-service', () => ({ }, })); +// Mock group-key service (used for group message encrypt/decrypt) +vi.mock('../group-key-service', () => ({ + groupKeyService: { + getGroupKeyForConversation: vi.fn(), + }, +})); + // Mock message cache vi.mock('@/lib/messaging/cache', () => ({ cacheService: { @@ -111,6 +118,7 @@ let encryptionService: any; let keyManagementService: any; let offlineQueueService: any; let cacheService: any; +let groupKeyService: any; // The service exports under test, re-imported per test after resetModules() let MessageService: any; @@ -175,6 +183,7 @@ beforeEach(async () => { ({ keyManagementService } = await import('../key-service')); ({ offlineQueueService } = await import('../offline-queue-service')); ({ cacheService } = await import('@/lib/messaging/cache')); + ({ groupKeyService } = await import('../group-key-service')); // Sensible defaults setOnline(true); @@ -382,20 +391,74 @@ describe('MessageService', () => { ).rejects.toThrow('encryption keys are not available'); }); - it('throws ValidationError for group conversations', async () => { + it('encrypts a group message with the group key and inserts with key_version', async () => { + setOnline(true); + keyManagementService.getCurrentKeys.mockReturnValue({ + privateKey: {} as CryptoKey, + publicKey: {} as CryptoKey, + }); + const groupKey = { type: 'group-key' } as unknown as CryptoKey; + groupKeyService.getGroupKeyForConversation.mockResolvedValue(groupKey); + encryptionService.encryptMessage.mockResolvedValue({ + ciphertext: 'group-cipher', + iv: 'group-iv', + }); + + const insertSpy = vi.fn().mockReturnThis(); mockMsgFrom.mockImplementation((table: string) => { if (table === 'conversations') { - return createMockQueryBuilder({ ...baseConv, is_group: true }, null); + return createMockQueryBuilder( + { ...baseConv, is_group: true, current_key_version: 3 }, + null + ); + } + if (table === 'messages') { + // last-sequence lookup returns null → nextSequenceNumber = 1; + // insert resolves a row. + return { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), + insert: (payload: any) => { + insertSpy(payload); + return { + select: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ + data: { id: MESSAGE_ID, ...payload }, + error: null, + }), + }; + }, + update: vi.fn().mockReturnThis(), + }; } return createMockQueryBuilder(null, null); }); - await expect( - messageService.sendMessage({ - conversation_id: CONVERSATION_ID, - content: 'group msg', + await messageService.sendMessage({ + conversation_id: CONVERSATION_ID, + content: 'group msg', + }); + + // Encrypted with the group key at the conversation's current_key_version… + expect(groupKeyService.getGroupKeyForConversation).toHaveBeenCalledWith( + CONVERSATION_ID, + 3 + ); + expect(encryptionService.encryptMessage).toHaveBeenCalledWith( + 'group msg', + groupKey + ); + // …and the row is stamped with that key_version. + expect(insertSpy).toHaveBeenCalledWith( + expect.objectContaining({ + encrypted_content: 'group-cipher', + initialization_vector: 'group-iv', + key_version: 3, }) - ).rejects.toThrow('Group message encryption not yet implemented'); + ); }); }); @@ -469,6 +532,71 @@ describe('MessageService', () => { expect(result.messages[0].content).toBe('Encrypted with previous keys'); }); + it('decrypts group messages with the group key at each message key_version', async () => { + const groupMsg = { ...messageRow, key_version: 2 }; + const groupKey = { type: 'group-key' } as unknown as CryptoKey; + groupKeyService.getGroupKeyForConversation.mockResolvedValue(groupKey); + encryptionService.decryptMessage.mockResolvedValue('group hello'); + + mockMsgFrom.mockImplementation((table: string) => { + if (table === 'messages') { + return createMockQueryBuilder([groupMsg], null); + } + if (table === 'conversations') { + return createMockQueryBuilder( + { ...conv, is_group: true, current_key_version: 2 }, + null + ); + } + if (table === 'user_profiles') { + return createMockQueryBuilder( + [{ id: OTHER_USER_ID, username: 'them', display_name: 'Them' }], + null + ); + } + return createMockQueryBuilder(null, null); + }); + + const result = await messageService.getMessageHistory(CONVERSATION_ID); + + // Resolved the group key by the MESSAGE's key_version (not the conv's). + expect(groupKeyService.getGroupKeyForConversation).toHaveBeenCalledWith( + CONVERSATION_ID, + 2 + ); + expect(encryptionService.decryptMessage).toHaveBeenCalledWith( + 'enc', + 'iv', + groupKey + ); + expect(result.messages).toHaveLength(1); + expect(result.messages[0].content).toBe('group hello'); + }); + + it('shows the placeholder when a group message fails to decrypt', async () => { + const groupMsg = { ...messageRow, key_version: 5 }; + groupKeyService.getGroupKeyForConversation.mockRejectedValue( + new Error('no key for version 5') + ); + mockMsgFrom.mockImplementation((table: string) => { + if (table === 'messages') { + return createMockQueryBuilder([groupMsg], null); + } + if (table === 'conversations') { + return createMockQueryBuilder({ ...conv, is_group: true }, null); + } + if (table === 'user_profiles') { + return createMockQueryBuilder([], null); + } + return createMockQueryBuilder(null, null); + }); + + const result = await messageService.getMessageHistory(CONVERSATION_ID); + + expect(result.messages[0].decryptionError).toBe(true); + expect(result.messages[0].content).toBe('Encrypted with previous keys'); + }); + it('falls back to IndexedDB cache when offline', async () => { setOnline(false); cacheService.getCachedMessages.mockResolvedValue([messageRow]); @@ -801,6 +929,30 @@ describe('MessageService', () => { messageService.archiveConversation(CONVERSATION_ID) ).rejects.toThrow('Conversation not found'); }); + + it('archives a group via conversation_members.archived', async () => { + const membersBuilder = createMockQueryBuilder(null, null); + membersBuilder.then = vi.fn((resolve) => + resolve({ data: [{ archived: true }], error: null }) + ); + mockMsgFrom.mockImplementation((table: string) => { + if (table === 'conversations') { + return createMockQueryBuilder( + { + participant_1_id: OTHER_USER_ID, + participant_2_id: THIRD_USER_ID, + is_group: true, + }, + null + ); + } + return membersBuilder; // conversation_members + }); + + await messageService.archiveConversation(CONVERSATION_ID); + + expect(membersBuilder.update).toHaveBeenCalledWith({ archived: true }); + }); }); // ----------------------------------------------------------------------- @@ -845,16 +997,29 @@ describe('MessageService', () => { ).rejects.toThrow('Failed to unarchive conversation: nope'); }); - it('throws for group conversations', async () => { - wireUnarchive({ - participant_1_id: CURRENT_USER_ID, - participant_2_id: OTHER_USER_ID, - is_group: true, + it('unarchives a group via conversation_members.archived', async () => { + // conversations fetch → is_group; conversation_members update → one row. + const membersBuilder = createMockQueryBuilder(null, null); + membersBuilder.then = vi.fn((resolve) => + resolve({ data: [{ archived: false }], error: null }) + ); + mockMsgFrom.mockImplementation((table: string) => { + if (table === 'conversations') { + return createMockQueryBuilder( + { + participant_1_id: OTHER_USER_ID, + participant_2_id: THIRD_USER_ID, + is_group: true, + }, + null + ); + } + return membersBuilder; // conversation_members }); - await expect( - messageService.unarchiveConversation(CONVERSATION_ID) - ).rejects.toThrow('Group conversation unarchiving not yet implemented'); + await messageService.unarchiveConversation(CONVERSATION_ID); + + expect(membersBuilder.update).toHaveBeenCalledWith({ archived: false }); }); }); diff --git a/src/services/messaging/message-service.ts b/src/services/messaging/message-service.ts index c271a01d..ec707791 100644 --- a/src/services/messaging/message-service.ts +++ b/src/services/messaging/message-service.ts @@ -12,6 +12,7 @@ import { createClient } from '@/lib/supabase/client'; import type { Session, SupabaseClient } from '@supabase/supabase-js'; import { encryptionService } from '@/lib/messaging/encryption'; import { keyManagementService } from './key-service'; +import { groupKeyService } from './group-key-service'; import { offlineQueueService } from './offline-queue-service'; import { cacheService } from '@/lib/messaging/cache'; import { createLogger } from '@/lib/logger'; @@ -73,7 +74,12 @@ async function getSessionWithRetry( // the first successful fetch. Keyed by conversation_id. const conversationCache = new Map< string, - { participant_1_id: string; participant_2_id: string; is_group: boolean } + { + participant_1_id: string; + participant_2_id: string; + is_group: boolean; + current_key_version?: number; + } >(); // Cache derived shared secrets so sendMessage can encrypt offline. @@ -98,6 +104,7 @@ export async function cacheConversationData( participant_1_id: string; participant_2_id: string; is_group: boolean; + current_key_version?: number; } ): Promise { conversationCache.set(conversationId, data); @@ -225,7 +232,9 @@ export class MessageService { if (!conversation) { const { data, error: convError } = await msgClient .from('conversations') - .select('participant_1_id, participant_2_id, is_group') + .select( + 'participant_1_id, participant_2_id, is_group, current_key_version' + ) .eq('id', input.conversation_id) .maybeSingle(); @@ -239,80 +248,92 @@ export class MessageService { participant_1_id: data.participant_1_id ?? '', participant_2_id: data.participant_2_id ?? '', is_group: data.is_group, + current_key_version: data.current_key_version ?? 1, }; conversation = cached; conversationCache.set(input.conversation_id, cached); } - // For 1-to-1 conversations, determine recipient ID - // Group conversations use symmetric encryption (handled separately in Phase 4) + // Encrypt the message content. Both paths produce the same + // { ciphertext, iv } and a key_version to stamp on the row: + // - Group conversations use the shared symmetric group key + // (already distributed per-member on join) keyed by + // current_key_version. + // - 1-to-1 conversations derive a per-pair ECDH shared secret. + let encrypted: Awaited< + ReturnType + >; + let keyVersion = 1; + if (conversation.is_group) { - throw new ValidationError( - 'Group message encryption not yet implemented', - 'conversation_id' + keyVersion = conversation.current_key_version ?? 1; + const groupKey = await groupKeyService.getGroupKeyForConversation( + input.conversation_id, + keyVersion ); - } + encrypted = await encryptionService.encryptMessage(content, groupKey); + } else { + const recipientId = + conversation.participant_1_id === user.id + ? conversation.participant_2_id + : conversation.participant_1_id; - const recipientId = - conversation.participant_1_id === user.id - ? conversation.participant_2_id - : conversation.participant_1_id; + if (!recipientId) { + throw new ValidationError( + 'Invalid conversation: no recipient found', + 'conversation_id' + ); + } - if (!recipientId) { - throw new ValidationError( - 'Invalid conversation: no recipient found', - 'conversation_id' - ); - } + // Invalidate shared secret cache if sender's keys changed + // (e.g. after resetEncryptionKeys + ReAuthModal re-derivation). + if (cachedSenderPrivateKey !== senderKeys.privateKey) { + sharedSecretCache.clear(); + cachedSenderPrivateKey = senderKeys.privateKey; + } - // Invalidate shared secret cache if sender's keys changed - // (e.g. after resetEncryptionKeys + ReAuthModal re-derivation). - if (cachedSenderPrivateKey !== senderKeys.privateKey) { - sharedSecretCache.clear(); - cachedSenderPrivateKey = senderKeys.privateKey; - } + // Get or derive shared secret for this recipient + let sharedSecret = sharedSecretCache.get(recipientId) ?? null; + const sharedSecretSource = sharedSecret ? 'cache' : 'derive'; + if (!sharedSecret) { + const recipientPublicKey = + await keyManagementService.getUserPublicKey(recipientId); + + if (!recipientPublicKey) { + throw new ValidationError( + "This person needs to sign in before you can message them. Messages cannot be delivered until they've logged in at least once to set up encryption.", + 'conversation_id' + ); + } - // Get or derive shared secret for this recipient - let sharedSecret = sharedSecretCache.get(recipientId) ?? null; - const sharedSecretSource = sharedSecret ? 'cache' : 'derive'; - if (!sharedSecret) { - const recipientPublicKey = - await keyManagementService.getUserPublicKey(recipientId); + const recipientPublicKeyCrypto = await crypto.subtle.importKey( + 'jwk', + recipientPublicKey, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [] + ); - if (!recipientPublicKey) { - throw new ValidationError( - "This person needs to sign in before you can message them. Messages cannot be delivered until they've logged in at least once to set up encryption.", - 'conversation_id' + sharedSecret = await encryptionService.deriveSharedSecret( + senderKeys.privateKey, + recipientPublicKeyCrypto ); + sharedSecretCache.set(recipientId, sharedSecret); } - const recipientPublicKeyCrypto = await crypto.subtle.importKey( - 'jwk', - recipientPublicKey, - { name: 'ECDH', namedCurve: 'P-256' }, - false, - [] - ); + logger.debug('sendMessage sharedSecret', { + source: sharedSecretSource, + recipientIdPrefix: recipientId.slice(0, 8), + online: navigator.onLine, + }); - sharedSecret = await encryptionService.deriveSharedSecret( - senderKeys.privateKey, - recipientPublicKeyCrypto + // Encrypt message content + encrypted = await encryptionService.encryptMessage( + content, + sharedSecret ); - sharedSecretCache.set(recipientId, sharedSecret); } - logger.debug('sendMessage sharedSecret', { - source: sharedSecretSource, - recipientIdPrefix: recipientId.slice(0, 8), - online: navigator.onLine, - }); - - // Encrypt message content - const encrypted = await encryptionService.encryptMessage( - content, - sharedSecret - ); - // Check if online - if offline, queue immediately if (!navigator.onLine) { const messageId = crypto.randomUUID(); @@ -323,6 +344,7 @@ export class MessageService { encrypted_content: encrypted.ciphertext, initialization_vector: encrypted.iv, content, + key_version: keyVersion, }); // Return a placeholder message object @@ -339,7 +361,7 @@ export class MessageService { delivered_at: null, read_at: null, created_at: new Date().toISOString(), - key_version: 1, + key_version: keyVersion, is_system_message: false, system_message_type: null, }; @@ -404,6 +426,7 @@ export class MessageService { encrypted_content: encrypted.ciphertext, initialization_vector: encrypted.iv, sequence_number: nextSequenceNumber, + key_version: keyVersion, deleted: false, edited: false, }) @@ -504,6 +527,7 @@ export class MessageService { encrypted_content: encrypted.ciphertext, initialization_vector: encrypted.iv, content, + key_version: keyVersion, }); // Return a placeholder message object @@ -520,7 +544,7 @@ export class MessageService { delivered_at: null, read_at: null, created_at: new Date().toISOString(), - key_version: 1, + key_version: keyVersion, is_system_message: false, system_message_type: null, }; @@ -679,7 +703,9 @@ export class MessageService { // Get conversation details for decryption const { data: conversation } = await msgClient .from('conversations') - .select('participant_1_id, participant_2_id, is_group') + .select( + 'participant_1_id, participant_2_id, is_group, current_key_version' + ) .eq('id', conversationId) .single(); @@ -687,12 +713,17 @@ export class MessageService { throw new ValidationError('Conversation not found', 'conversationId'); } - // Group conversations use symmetric encryption (handled separately in Phase 4) + // Group conversations use symmetric encryption: each message is decrypted + // with the group key at that message's key_version (so messages sent + // before a key rotation still decrypt). Resolve keys lazily + cache by + // version to avoid re-deriving per message. if (conversation.is_group) { - throw new ValidationError( - 'Group message decryption not yet implemented', - 'conversationId' - ); + return await this.getGroupMessageHistory({ + conversationId, + userId: user.id, + messagesToDecrypt, + hasMore, + }); } // Determine other participant (1-to-1 only) @@ -906,6 +937,107 @@ export class MessageService { } } + /** + * Decrypt a page of GROUP-conversation messages. + * + * Unlike 1:1 (one ECDH shared secret for the whole page), each group message + * is decrypted with the symmetric group key at its own `key_version` — so + * messages sent before a key rotation still decrypt. Group keys are resolved + * lazily and cached per version (groupKeyService also caches internally). + * Decryption failures degrade to a placeholder, matching the 1:1 path. + */ + private async getGroupMessageHistory(params: { + conversationId: string; + userId: string; + messagesToDecrypt: Message[]; + hasMore: boolean; + }): Promise { + const { conversationId, userId, messagesToDecrypt, hasMore } = params; + const msgClient = createMessagingClient(createClient()); + + if (messagesToDecrypt.length === 0) { + return { messages: [], has_more: false, cursor: null }; + } + + // Fetch display profiles for every distinct sender in this page. + const senderIds = [...new Set(messagesToDecrypt.map((m) => m.sender_id))]; + const { data: profiles } = await msgClient + .from('user_profiles') + .select('id, username, display_name, avatar_url') + .in('id', senderIds); + const profileMap = new Map(); + profiles?.forEach((p) => profileMap.set(p.id, p as UserProfile)); + + // Resolve (and memoize) the group key for a given key_version. + const keyByVersion = new Map>(); + const groupKeyFor = (version: number): Promise => { + let keyPromise = keyByVersion.get(version); + if (!keyPromise) { + keyPromise = groupKeyService.getGroupKeyForConversation( + conversationId, + version + ); + keyByVersion.set(version, keyPromise); + } + return keyPromise; + }; + + const decryptedMessages: DecryptedMessage[] = await Promise.all( + messagesToDecrypt.map(async (msg) => { + const senderProfile = profileMap.get(msg.sender_id); + const base = { + id: msg.id, + conversation_id: msg.conversation_id, + sender_id: msg.sender_id, + sequence_number: msg.sequence_number, + deleted: msg.deleted, + edited: msg.edited, + edited_at: msg.edited_at, + delivered_at: msg.delivered_at, + read_at: msg.read_at, + created_at: msg.created_at, + isOwn: msg.sender_id === userId, + senderName: + senderProfile?.display_name || senderProfile?.username || 'Unknown', + }; + try { + const groupKey = await groupKeyFor(msg.key_version ?? 1); + const content = await encryptionService.decryptMessage( + msg.encrypted_content, + msg.initialization_vector, + groupKey + ); + return { ...base, content }; + } catch (decryptError) { + const err = decryptError as Error; + logger.error('Group message decryption FAILED', { + messageId: msg.id, + keyVersion: msg.key_version, + errorName: String(err.name || 'unknown'), + errorMessage: String(err.message || 'no message'), + }); + return { + ...base, + content: 'Encrypted with previous keys', + decryptionError: true, + }; + } + }) + ); + + // Chronological order (oldest first), matching the 1:1 path. + decryptedMessages.reverse(); + + return { + messages: decryptedMessages, + has_more: hasMore, + cursor: + decryptedMessages.length > 0 + ? decryptedMessages[0].sequence_number + : null, + }; + } + /** * Mark messages as read by updating their read_at timestamp * Task: T063 @@ -1106,7 +1238,9 @@ export class MessageService { // Get conversation details for encryption const { data: conversation, error: convError } = await msgClient .from('conversations') - .select('participant_1_id, participant_2_id, is_group') + .select( + 'participant_1_id, participant_2_id, is_group, current_key_version' + ) .eq('id', message.conversation_id) .single(); @@ -1114,69 +1248,78 @@ export class MessageService { throw new ValidationError('Conversation not found', 'conversation_id'); } - // Group conversations use symmetric encryption (handled separately in Phase 4) + // Re-encrypt the edited content. Groups use the current group key + // (a rotation may have happened since the original send, so re-key to + // the latest version); 1:1 derives the per-pair ECDH shared secret. + let encrypted: Awaited< + ReturnType + >; + let keyVersion = 1; + if (conversation.is_group) { - throw new ValidationError( - 'Group message editing not yet implemented', - 'conversation_id' + keyVersion = conversation.current_key_version ?? 1; + const groupKey = await groupKeyService.getGroupKeyForConversation( + message.conversation_id, + keyVersion ); - } + encrypted = await encryptionService.encryptMessage(content, groupKey); + } else { + // Determine recipient ID (1-to-1 only) + const recipientId = + conversation.participant_1_id === user.id + ? conversation.participant_2_id + : conversation.participant_1_id; - // Determine recipient ID (1-to-1 only) - const recipientId = - conversation.participant_1_id === user.id - ? conversation.participant_2_id - : conversation.participant_1_id; + if (!recipientId) { + throw new ValidationError( + 'Invalid conversation: no recipient found', + 'conversation_id' + ); + } - if (!recipientId) { - throw new ValidationError( - 'Invalid conversation: no recipient found', - 'conversation_id' - ); - } + // Get sender's derived keys from memory (with cache restore fallback) + let senderKeys = keyManagementService.getCurrentKeys(); + if (!senderKeys) { + await keyManagementService.restoreKeysFromCache(user.id); + senderKeys = keyManagementService.getCurrentKeys(); + } + if (!senderKeys) { + throw new EncryptionLockedError( + 'Your encryption keys are not available. Please sign in again to edit messages.' + ); + } - // Get sender's derived keys from memory (with cache restore fallback) - let senderKeys = keyManagementService.getCurrentKeys(); - if (!senderKeys) { - await keyManagementService.restoreKeysFromCache(user.id); - senderKeys = keyManagementService.getCurrentKeys(); - } - if (!senderKeys) { - throw new EncryptionLockedError( - 'Your encryption keys are not available. Please sign in again to edit messages.' - ); - } + // Get recipient's public key + const recipientPublicKey = + await keyManagementService.getUserPublicKey(recipientId); - // Get recipient's public key - const recipientPublicKey = - await keyManagementService.getUserPublicKey(recipientId); + if (!recipientPublicKey) { + throw new EncryptionError( + 'Cannot edit message: recipient encryption keys not available' + ); + } - if (!recipientPublicKey) { - throw new EncryptionError( - 'Cannot edit message: recipient encryption keys not available' + // Import recipient's public key + const recipientPublicKeyCrypto = await crypto.subtle.importKey( + 'jwk', + recipientPublicKey, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [] ); - } - // Import recipient's public key - const recipientPublicKeyCrypto = await crypto.subtle.importKey( - 'jwk', - recipientPublicKey, - { name: 'ECDH', namedCurve: 'P-256' }, - false, - [] - ); - - // Derive shared secret using sender's derived private key - const sharedSecret = await encryptionService.deriveSharedSecret( - senderKeys.privateKey, - recipientPublicKeyCrypto - ); + // Derive shared secret using sender's derived private key + const sharedSecret = await encryptionService.deriveSharedSecret( + senderKeys.privateKey, + recipientPublicKeyCrypto + ); - // Encrypt new content - const encrypted = await encryptionService.encryptMessage( - content, - sharedSecret - ); + // Encrypt new content + encrypted = await encryptionService.encryptMessage( + content, + sharedSecret + ); + } // Update message in database const { error: updateError } = await msgClient @@ -1184,6 +1327,7 @@ export class MessageService { .update({ encrypted_content: encrypted.ciphertext, initialization_vector: encrypted.iv, + key_version: keyVersion, edited: true, edited_at: new Date().toISOString(), }) @@ -1330,12 +1474,32 @@ export class MessageService { throw new ValidationError('Conversation not found', 'conversationId'); } - // Group conversations use conversation_members.archived field + // Group conversations archive per-member via conversation_members.archived + // (1:1 uses the conversation-level archived_by_participant_N columns below). if (conversation.is_group) { - throw new ValidationError( - 'Group conversation archiving not yet implemented', - 'conversationId' - ); + const { error: memberError, data: memberData } = await msgClient + .from('conversation_members') + .update({ archived: true }) + .eq('conversation_id', conversationId) + .eq('user_id', user.id) + .is('left_at', null) + .select(); + + if (memberError) { + throw new ConnectionError( + 'Failed to archive group conversation: ' + memberError.message + ); + } + if (!memberData || memberData.length === 0) { + throw new ValidationError( + 'You are not a member of this conversation', + 'conversationId' + ); + } + logger.debug('archiveConversation (group) success', { + conversationId, + }); + return; } // Determine which column to update based on user's participant role (1-to-1 only) @@ -1416,12 +1580,32 @@ export class MessageService { throw new ValidationError('Conversation not found', 'conversationId'); } - // Group conversations use conversation_members.archived field + // Group conversations unarchive per-member via conversation_members.archived + // (1:1 uses the conversation-level archived_by_participant_N columns below). if (conversation.is_group) { - throw new ValidationError( - 'Group conversation unarchiving not yet implemented', - 'conversationId' - ); + const { error: memberError, data: memberData } = await msgClient + .from('conversation_members') + .update({ archived: false }) + .eq('conversation_id', conversationId) + .eq('user_id', user.id) + .is('left_at', null) + .select(); + + if (memberError) { + throw new ConnectionError( + 'Failed to unarchive group conversation: ' + memberError.message + ); + } + if (!memberData || memberData.length === 0) { + throw new ValidationError( + 'You are not a member of this conversation', + 'conversationId' + ); + } + logger.debug('unarchiveConversation (group) success', { + conversationId, + }); + return; } // Determine which column to update based on user's participant role (1-to-1 only) diff --git a/src/services/messaging/offline-queue-service.ts b/src/services/messaging/offline-queue-service.ts index 63c61f6b..41407869 100644 --- a/src/services/messaging/offline-queue-service.ts +++ b/src/services/messaging/offline-queue-service.ts @@ -63,6 +63,7 @@ export class OfflineQueueService { | 'encrypted_content' | 'initialization_vector' | 'content' + | 'key_version' > ): Promise { const queuedMessage: QueuedMessage = { @@ -215,6 +216,11 @@ export class OfflineQueueService { encrypted_content: queuedMsg.encrypted_content, initialization_vector: queuedMsg.initialization_vector, sequence_number: nextSequenceNumber, + // Preserve the group-key version the content was encrypted under + // (undefined for 1:1 → the column defaults to 1). + ...(queuedMsg.key_version != null + ? { key_version: queuedMsg.key_version } + : {}), deleted: false, edited: false, }) diff --git a/src/types/messaging.ts b/src/types/messaging.ts index 89f64e6f..4dfdc748 100644 --- a/src/types/messaging.ts +++ b/src/types/messaging.ts @@ -111,6 +111,13 @@ export interface QueuedMessage { retries: number; created_at: number; // Unix timestamp sequence_number?: number; // Optional sequence number after successful send + /** + * Group-key version the content was encrypted under. Only set for group + * conversations; 1:1 messages leave it undefined (the messages table + * defaults key_version to 1). Preserved so a message queued offline under + * one group-key version still decrypts after a later rotation. + */ + key_version?: number; } /** diff --git a/supabase/migrations/20251006_complete_monolithic_setup.sql b/supabase/migrations/20251006_complete_monolithic_setup.sql index 3046ad9e..066a7dbc 100644 --- a/supabase/migrations/20251006_complete_monolithic_setup.sql +++ b/supabase/migrations/20251006_complete_monolithic_setup.sql @@ -1665,6 +1665,19 @@ DROP POLICY IF EXISTS "Users can view own conversations" ON conversations; CREATE POLICY "Users can view own conversations" ON conversations FOR SELECT USING (auth.uid() = participant_1_id OR auth.uid() = participant_2_id); +-- Users can view GROUP conversations they belong to (Feature 010 / #182). +-- Groups have participant_1_id/participant_2_id NULL, so the 1:1 policy above +-- never matches them. Access = the creator OR any member. The `created_by` +-- clause is load-bearing for createGroup(): its INSERT ... RETURNING reads the +-- new row back BEFORE the creator is added to conversation_members, so a +-- membership-only check would 403 the returning select and break creation. +DROP POLICY IF EXISTS "Members can view group conversations" ON conversations; +CREATE POLICY "Members can view group conversations" ON conversations + FOR SELECT USING ( + is_group = true + AND (created_by = auth.uid() OR is_conversation_member(id)) + ); + DROP POLICY IF EXISTS "Users can create conversations with connections" ON conversations; CREATE POLICY "Users can create conversations with connections" ON conversations FOR INSERT WITH CHECK ( @@ -1678,6 +1691,19 @@ CREATE POLICY "Users can create conversations with connections" ON conversations ) ); +-- Users can create GROUP conversations they own (Feature 010 / #182). +-- Groups have participant_1_id/participant_2_id NULL (CHK023), so the 1:1 +-- "auth.uid() = participant_N" policies above can never authorize a group +-- INSERT — without this policy, createGroup()'s conversation insert 403s for +-- every non-admin user and group messaging is unreachable. The creator must +-- be the owner (created_by); per-member access is enforced by the +-- conversation_members policies. +DROP POLICY IF EXISTS "Users can create group conversations" ON conversations; +CREATE POLICY "Users can create group conversations" ON conversations + FOR INSERT WITH CHECK ( + is_group = true AND created_by = auth.uid() + ); + -- Admin can create conversations with any user (Feature 002 - welcome messages) DROP POLICY IF EXISTS "Admin can create any conversation" ON conversations; CREATE POLICY "Admin can create any conversation" ON conversations @@ -2164,6 +2190,12 @@ DROP POLICY IF EXISTS "Members can view conversation members" ON conversation_me CREATE POLICY "Members can view conversation members" ON conversation_members FOR SELECT USING ( is_conversation_member(conversation_id) + -- The creator must also read member rows they're inserting during + -- createGroup(): the INSERT ... RETURNING on conversation_members runs + -- while membership is still being established, so a membership-only check + -- races (is_conversation_member can't see the just-inserted rows yet) and + -- the returning read intermittently 403s → "Failed to add group members". + OR is_conversation_creator(conversation_id) ); -- INSERT: existing members can add others (connection validation in the diff --git a/tests/e2e/messaging/group-chat-multiuser.spec.ts b/tests/e2e/messaging/group-chat-multiuser.spec.ts index 159f0d63..bfe43e4b 100644 --- a/tests/e2e/messaging/group-chat-multiuser.spec.ts +++ b/tests/e2e/messaging/group-chat-multiuser.spec.ts @@ -18,6 +18,7 @@ import { openAuthedPage, handleReAuthModal, dismissCookieBanner, + fillMessageInput, DEFAULT_TEST_PASSWORD, type IsolatedConnection, } from '../utils/test-user-factory'; @@ -183,6 +184,101 @@ test.describe('Group Chat E2E', () => { } }); + test('sends and reads an encrypted message in a UI-created group (#182)', async ({ + browser, + }) => { + // Creating the group through the UI runs createGroup() in-browser, which + // generates + distributes the group AES key (group_keys). Then sending + + // re-opening the group exercises the real group-message encrypt/decrypt + // paths — which used to throw "not yet implemented". + const viewer = await openMessagesAsViewer(browser, fixture!); + try { + const sidebar = viewer.page.locator('[data-testid="unified-sidebar"]'); + await expect(sidebar).toBeVisible({ timeout: 30000 }); + await sidebar.locator('a:has-text("New Group")').first().click(); + await viewer.page.waitForURL(/.*\/messages\/new-group/, { + timeout: 10000, + }); + + await viewer.page.locator('#group-name').fill(`Grp ${Date.now()}`); + const firstConnection = viewer.page + .locator('button[role="option"]') + .first(); + await expect(firstConnection).toBeVisible({ timeout: 30000 }); + await firstConnection.click(); + + const createButton = viewer.page.locator( + 'button:has-text("Create Group")' + ); + await expect(createButton).toBeEnabled({ timeout: 15000 }); + + // Capture browser console so a createGroup failure is visible in logs. + viewer.page.on('console', (msg) => { + if (msg.type() === 'error') { + console.log(`[browser error] ${msg.text()}`); + } + }); + + await createButton.click(); + + // createGroup runs in-browser: generate + ECDH-distribute the group key + // to every member, then insert (Argon2/ECDH — can take a while). Re-deriving + // the private key may surface the ReAuth modal at an unpredictable moment + // during that async work, so poll for it while waiting for the navigation + // to /messages?conversation= (or an error alert). + const errorAlert = viewer.page.locator('.alert-error'); + const deadline = Date.now() + 90000; + let navigated = false; + while (Date.now() < deadline) { + await handleReAuthModal(viewer.page, DEFAULT_TEST_PASSWORD).catch( + () => {} + ); + if (/\/messages\?conversation=/.test(viewer.page.url())) { + navigated = true; + break; + } + // A REAL group-creation error (e.g. the RLS 403s this feature fixed) + // must fail the test — that's the regression we're guarding. + if (await errorAlert.isVisible().catch(() => false)) { + const msg = await errorAlert.innerText().catch(() => ''); + throw new Error(`Group creation surfaced an error: ${msg}`); + } + await viewer.page.waitForTimeout(1000); + } + // If creation neither errored NOR navigated in 90s, the throwaway-user + // Argon2/ECDH key distribution was just too slow on this runner — skip + // rather than flake-fail. The encrypt/decrypt LOGIC is covered by the + // message-service unit tests; this spec guards the "group is reachable, + // no 'not yet implemented' throw, message round-trips" flow when the + // environment is fast enough to create the group. + test.skip( + !navigated, + 'group creation did not complete within 90s on this runner (slow Argon2/ECDH)' + ); + + await handleReAuthModal(viewer.page, DEFAULT_TEST_PASSWORD).catch( + () => {} + ); + + // No "not yet implemented" / decryption error surfaced on open. + await expect( + viewer.page.locator('text=/not yet implemented/i') + ).toHaveCount(0); + + // Send a message into the group (encrypt path). + const body = `group hello ${Date.now()}`; + await fillMessageInput(viewer.page, body); + await viewer.page.getByRole('button', { name: /Send message/i }).click(); + + // The sent message renders (own message decrypts + displays). + await expect(viewer.page.getByText(body)).toBeVisible({ + timeout: 20000, + }); + } finally { + await viewer.close(); + } + }); + test('should navigate back to messages when clicking back button', async ({ browser, }) => {