import { bytesToUtf8, utf8ToBytes } from '../crypto/index'; import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea'; import type { AppSupabaseClient } from '../supabase/client'; import { decryptWithConvKey, encryptWithConvKey, getOrCreateConvKey, type OwnUserCtx, tryGetConvKey, } from './convKeys'; import type { ChatMessage, DecryptedMessage } from './types'; const MESSAGE_COLS = 'id, conversation_id, sender_id, sender_device_id, reply_to_id, edited_at, deleted_at, created_at, ciphertext, nonce, key_version'; interface MessageRow { id: string; conversation_id: string; sender_id: string; sender_device_id: string | null; reply_to_id: string | null; edited_at: string | null; deleted_at: string | null; created_at: string; ciphertext: string; nonce: string; key_version: number; } interface MessageWithCipher extends ChatMessage { ciphertext: Uint8Array; nonce: Uint8Array; keyVersion: number; } function mapMessage(row: MessageRow): MessageWithCipher { return { id: row.id, conversationId: row.conversation_id, senderId: row.sender_id, senderDeviceId: row.sender_device_id, replyToId: row.reply_to_id, editedAt: row.edited_at, deletedAt: row.deleted_at, createdAt: row.created_at, ciphertext: pgHexToBytes(row.ciphertext), nonce: pgHexToBytes(row.nonce), keyVersion: row.key_version, }; } interface DeviceKey { deviceId: string; userId: string; publicKey: Uint8Array; } // All device public keys for accepted members of a conversation. export async function listConversationDeviceKeys( client: AppSupabaseClient, conversationId: string, ): Promise { // 1. Members const { data: members, error: mErr } = await client .from('conversation_members') .select('user_id, accepted') .eq('conversation_id', conversationId); if (mErr) throw mErr; const memberIds = (members ?? []).filter((m) => m.accepted).map((m) => m.user_id); if (memberIds.length === 0) return []; // 2. Devices for those members const { data: devices, error: dErr } = await client .from('devices') .select('id, user_id, public_key') .in('user_id', memberIds); if (dErr) throw dErr; return (devices ?? []) .filter((d): d is typeof d & { public_key: string } => d.public_key !== null) .map((d) => ({ deviceId: d.id, userId: d.user_id, publicKey: pgHexToBytes(d.public_key), })); } export interface SendMessageParams { client: AppSupabaseClient; conversationId: string; plaintext: string; senderUserId: string; senderDeviceId: string; senderPrivateKey: Uint8Array; replyToId?: string; // Optional encrypted attachments — their handles are already materialised // via `encryptAndUploadAttachment`. The caller is responsible for creating // the corresponding message_attachments rows (see insertAttachmentRow) once // the returned message id is known. attachmentHandles?: import('./attachments.js').AttachmentHandle[]; } // Encrypts and inserts a message using the shared per-conversation key // (Sender-Key / Signal-style). The conv-key is generated lazily on first // send and shared with every existing recipient device. New devices that // register later receive their key bundle through `shareConvKeyToDevice`. export async function sendEncryptedMessage(params: SendMessageParams): Promise { const ownCtx: OwnUserCtx = { userId: params.senderUserId, privateKey: params.senderPrivateKey, }; const handle = await getOrCreateConvKey(params.client, params.conversationId, ownCtx); const attachments = params.attachmentHandles ?? []; const payloadString = attachments.length === 0 ? params.plaintext : JSON.stringify({ v: 1, text: params.plaintext, attachments }); const plainBytes = utf8ToBytes(payloadString); const cipher = encryptWithConvKey(plainBytes, handle.key); const insertPayload: Record = { conversation_id: params.conversationId, sender_id: params.senderUserId, sender_device_id: params.senderDeviceId, ciphertext: bytesToPgHex(cipher.ciphertext), nonce: bytesToPgHex(cipher.nonce), key_version: handle.keyVersion, }; if (params.replyToId) insertPayload.reply_to_id = params.replyToId; const { data: messageRow, error: insertErr } = await params.client .from('messages') .insert(insertPayload as never) .select(MESSAGE_COLS) .single(); if (insertErr) throw insertErr; return mapMessage(messageRow as unknown as MessageRow); } // Fetch the last `limit` messages of a conversation in ascending order. export async function fetchConversationMessages( client: AppSupabaseClient, conversationId: string, limit = 100, ): Promise { const { data, error } = await client .from('messages') .select(MESSAGE_COLS) .eq('conversation_id', conversationId) .order('created_at', { ascending: false }) .limit(limit); if (error) throw error; const rows = (data ?? []) as unknown as MessageRow[]; return rows.map(mapMessage).reverse(); } export type { MessageWithCipher }; // --------------------------------------------------------------------------- // Edit + delete // --------------------------------------------------------------------------- export interface EditMessageParams { client: AppSupabaseClient; messageId: string; conversationId: string; newPlaintext: string; senderPrivateKey: Uint8Array; } // Re-encrypts the message body with the conv-key and updates the row. // Server-side trigger enforces 24h window + sender-only rule. export async function editEncryptedMessage( params: EditMessageParams & { senderUserId: string; senderDeviceId: string }, ): Promise { const ownCtx: OwnUserCtx = { userId: params.senderUserId, privateKey: params.senderPrivateKey, }; const handle = await getOrCreateConvKey(params.client, params.conversationId, ownCtx); const cipher = encryptWithConvKey(utf8ToBytes(params.newPlaintext), handle.key); const { error } = await params.client .from('messages') .update({ ciphertext: bytesToPgHex(cipher.ciphertext), nonce: bytesToPgHex(cipher.nonce), key_version: handle.keyVersion, edited_at: new Date().toISOString(), } as never) .eq('id', params.messageId); if (error) throw error; } export async function softDeleteMessage( client: AppSupabaseClient, messageId: string, ): Promise { const { error } = await client .from('messages') .update({ deleted_at: new Date().toISOString() } as never) .eq('id', messageId); if (error) throw error; } // --------------------------------------------------------------------------- // Read receipts // --------------------------------------------------------------------------- // Upsert read rows for each message. Idempotent thanks to the composite PK. export async function markMessagesRead( client: AppSupabaseClient, messageIds: string[], ): Promise { if (messageIds.length === 0) return; const { data: session, error: aErr } = await client.auth.getUser(); if (aErr) throw aErr; if (!session.user) return; const myId = session.user.id; const rows = messageIds.map((id) => ({ message_id: id, user_id: myId })); const { error } = await client .from('message_reads') .upsert(rows as never, { onConflict: 'message_id,user_id', ignoreDuplicates: true }); if (error) throw error; } // Returns the set of message ids that `peerUserId` has read (among the given ids). // RLS hides rows when either side has read receipts off. export async function listPeerReadsForMessages( client: AppSupabaseClient, messageIds: string[], peerUserId: string, ): Promise> { if (messageIds.length === 0) return new Set(); const { data, error } = await client .from('message_reads') .select('message_id') .eq('user_id', peerUserId) .in('message_id', messageIds); if (error) throw error; return new Set((data ?? []).map((r) => r.message_id)); } // Group variant: returns reads from ALL users (other than `excludeUserId`, // typically caller themselves) keyed by message id → user id → timestamp. // RLS already filters out users whose receipts are off. export async function listGroupReadsForMessages( client: AppSupabaseClient, messageIds: string[], excludeUserId: string, ): Promise>> { if (messageIds.length === 0) return new Map(); const { data, error } = await client .from('message_reads') .select('message_id, user_id, read_at') .neq('user_id', excludeUserId) .in('message_id', messageIds); if (error) throw error; const out = new Map>(); for (const row of data ?? []) { const mid = row.message_id; const uid = row.user_id; const at = row.read_at; let inner = out.get(mid); if (!inner) { inner = new Map(); out.set(mid, inner); } inner.set(uid, at); } return out; } // Group variant of delivery receipts. Same shape as listGroupReadsForMessages. export async function listGroupDeliveriesForMessages( client: AppSupabaseClient, messageIds: string[], excludeUserId: string, ): Promise>> { if (messageIds.length === 0) return new Map(); const { data, error } = await (client as unknown as { from: (t: string) => { select: (cols: string) => { neq: (col: string, val: string) => { in: ( col: string, vals: string[], ) => Promise<{ data: | { message_id: string; user_id: string; delivered_at: string }[] | null; error: Error | null; }>; }; }; }; }) .from('message_deliveries') .select('message_id, user_id, delivered_at') .neq('user_id', excludeUserId) .in('message_id', messageIds); if (error) throw error; const out = new Map>(); for (const row of data ?? []) { let inner = out.get(row.message_id); if (!inner) { inner = new Map(); out.set(row.message_id, inner); } inner.set(row.user_id, row.delivered_at); } return out; } // --------------------------------------------------------------------------- // Delivery receipts // --------------------------------------------------------------------------- // Records that the caller has received (fetched + decrypted) these messages. // Idempotent; composite PK absorbs duplicates. export async function markMessagesDelivered( client: AppSupabaseClient, messageIds: string[], ): Promise { if (messageIds.length === 0) return; const { data: session, error: aErr } = await client.auth.getUser(); if (aErr) throw aErr; if (!session.user) return; const myId = session.user.id; const rows = messageIds.map((id) => ({ message_id: id, user_id: myId })); // `message_deliveries` is a later migration and not yet in the generated // Supabase types; skip the strict table-name check for this call. const { error } = await (client as unknown as { from: (t: string) => { upsert: ( rows: unknown, opts: { onConflict: string; ignoreDuplicates: boolean }, ) => Promise<{ error: Error | null }>; }; }) .from('message_deliveries') .upsert(rows, { onConflict: 'message_id,user_id', ignoreDuplicates: true }); if (error) throw error; } export async function listPeerDeliveriesForMessages( client: AppSupabaseClient, messageIds: string[], peerUserId: string, ): Promise> { if (messageIds.length === 0) return new Set(); const { data, error } = await (client as unknown as { from: (t: string) => { select: (cols: string) => { eq: (col: string, val: string) => { in: ( col: string, vals: string[], ) => Promise<{ data: { message_id: string }[] | null; error: Error | null }>; }; }; }; }) .from('message_deliveries') .select('message_id') .eq('user_id', peerUserId) .in('message_id', messageIds); if (error) throw error; return new Set((data ?? []).map((r) => r.message_id)); } // --------------------------------------------------------------------------- // Reactions // --------------------------------------------------------------------------- export interface MessageReaction { messageId: string; userId: string; emoji: string; createdAt: string; } export async function listReactionsForMessages( client: AppSupabaseClient, messageIds: string[], ): Promise { if (messageIds.length === 0) return []; const { data, error } = await client .from('message_reactions') .select('message_id, user_id, emoji, created_at') .in('message_id', messageIds); if (error) throw error; return (data ?? []).map((r) => ({ messageId: r.message_id, userId: r.user_id, emoji: r.emoji, createdAt: r.created_at, })); } export async function addReaction( client: AppSupabaseClient, messageId: string, emoji: string, ): Promise { const { data: session, error: aErr } = await client.auth.getUser(); if (aErr) throw aErr; if (!session.user) throw new Error('not authenticated'); const { error } = await client.from('message_reactions').insert({ message_id: messageId, user_id: session.user.id, emoji, } as never); if (error) throw error; } export async function removeReaction( client: AppSupabaseClient, messageId: string, emoji: string, ): Promise { const { data: session, error: aErr } = await client.auth.getUser(); if (aErr) throw aErr; if (!session.user) throw new Error('not authenticated'); const { error } = await client .from('message_reactions') .delete() .eq('message_id', messageId) .eq('user_id', session.user.id) .eq('emoji', emoji); if (error) throw error; } // --------------------------------------------------------------------------- // Decrypt helpers // --------------------------------------------------------------------------- export interface DecryptParams { client: AppSupabaseClient; messages: MessageWithCipher[]; ownUserId: string; ownPrivateKey: Uint8Array; /** * Optional delegate that performs the symmetric-decrypt + utf-8 decode * step for a batch of messages. When provided, the main thread only does * conv-key lookup; the CPU-heavy AEAD loop runs inside the delegate (e.g. * a Web Worker). Items arrive with their per-message conv-key attached. */ aeadBatchDelegate?: ( items: Array<{ id: string; ciphertext: Uint8Array; nonce: Uint8Array; key: Uint8Array; }>, ) => Promise>; } // Decrypts messages using their conv-key (looked up + cached per // keyVersion). Returns null `plaintext` when this device has no key bundle // for that version yet (e.g. brand-new device waiting for share). export async function decryptMessages(opts: DecryptParams): Promise { const versions = new Map>(); // convId -> version -> key | null // First pass: resolve conv-keys for every message. Network-bound, stays on // caller's thread so Supabase client + session remain usable. interface Resolved { message: MessageWithCipher; key: Uint8Array | null; } const resolved: Resolved[] = []; for (const m of opts.messages) { let convCache = versions.get(m.conversationId); if (!convCache) { convCache = new Map(); versions.set(m.conversationId, convCache); } let key: Uint8Array | null; if (convCache.has(m.keyVersion)) { key = convCache.get(m.keyVersion) ?? null; } else { const handle = await tryGetConvKey( opts.client, m.conversationId, opts.ownUserId, opts.ownPrivateKey, m.keyVersion, ); key = handle?.key ?? null; convCache.set(m.keyVersion, key); } resolved.push({ message: m, key }); } // Second pass: symmetric decrypt. If a delegate is supplied (Web Worker), // batch the CPU-heavy step to it; otherwise fall back to inline. if (opts.aeadBatchDelegate) { const batch = resolved .filter((r): r is Resolved & { key: Uint8Array } => r.key !== null) .map((r) => ({ id: r.message.id, ciphertext: r.message.ciphertext, nonce: r.message.nonce, key: r.key, })); const plaintextById = new Map(); if (batch.length > 0) { const results = await opts.aeadBatchDelegate(batch); for (const result of results) plaintextById.set(result.id, result.plaintext); } return resolved.map((r) => ({ ...r.message, plaintext: r.key ? plaintextById.get(r.message.id) ?? null : null, })); } return resolved.map((r) => { let plaintext: string | null = null; if (r.key) { try { const decoded = decryptWithConvKey(r.message.ciphertext, r.message.nonce, r.key); plaintext = bytesToUtf8(decoded); } catch { plaintext = null; } } return { ...r.message, plaintext }; }); }