This commit is contained in:
2026-04-18 23:11:35 +02:00
commit f7cfd2a86e
196 changed files with 35538 additions and 0 deletions
+394
View File
@@ -0,0 +1,394 @@
import {
bytesToUtf8,
decryptFrom,
encryptFor,
utf8ToBytes,
} from '../crypto/index.js';
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea.js';
import type { AppSupabaseClient } from '../supabase/client.js';
import type { ChatMessage, DecryptedMessage } from './types.js';
const MESSAGE_COLS =
'id, conversation_id, sender_id, sender_device_id, reply_to_id, edited_at, deleted_at, created_at';
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;
}
function mapMessage(row: MessageRow): ChatMessage {
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,
};
}
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<DeviceKey[]> {
// 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 ?? []).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 + per-device envelopes (one per recipient
// device, including the sender's own devices so multi-device sender devices
// can decrypt their own outbox).
export async function sendEncryptedMessage(params: SendMessageParams): Promise<ChatMessage> {
const deviceKeys = await listConversationDeviceKeys(params.client, params.conversationId);
if (deviceKeys.length === 0) {
throw new Error('no recipient devices found');
}
const attachments = params.attachmentHandles ?? [];
const payloadString =
attachments.length === 0
? params.plaintext
: JSON.stringify({ v: 1, text: params.plaintext, attachments });
const plainBytes = utf8ToBytes(payloadString);
// Insert the message metadata first.
const insertPayload: Record<string, unknown> = {
conversation_id: params.conversationId,
sender_id: params.senderUserId,
sender_device_id: params.senderDeviceId,
};
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;
const msg = mapMessage(messageRow as unknown as MessageRow);
// Encrypt one envelope per recipient device (including own devices).
const envelopes: { message_id: string; recipient_device_id: string; ciphertext: string; nonce: string }[] = [];
for (const dk of deviceKeys) {
const { ciphertext, nonce } = await encryptFor(plainBytes, dk.publicKey, params.senderPrivateKey);
envelopes.push({
message_id: msg.id,
recipient_device_id: dk.deviceId,
ciphertext: bytesToPgHex(ciphertext),
nonce: bytesToPgHex(nonce),
});
}
const { error: envErr } = await params.client
.from('message_envelopes')
.insert(envelopes as never);
if (envErr) {
// Best-effort cleanup if envelope insert failed.
await params.client.from('messages').delete().eq('id', msg.id);
throw envErr;
}
return msg;
}
// Fetch the last `limit` messages of a conversation in ascending order.
export async function fetchConversationMessages(
client: AppSupabaseClient,
conversationId: string,
limit = 100,
): Promise<ChatMessage[]> {
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();
}
// Pull envelopes targeted at our own device for a batch of message ids.
export async function fetchOwnEnvelopes(
client: AppSupabaseClient,
messageIds: string[],
ownDeviceId: string,
): Promise<Map<string, { ciphertext: Uint8Array; nonce: Uint8Array }>> {
if (messageIds.length === 0) return new Map();
const { data, error } = await client
.from('message_envelopes')
.select('message_id, ciphertext, nonce')
.in('message_id', messageIds)
.eq('recipient_device_id', ownDeviceId);
if (error) throw error;
const out = new Map<string, { ciphertext: Uint8Array; nonce: Uint8Array }>();
for (const row of data ?? []) {
out.set(row.message_id, {
ciphertext: pgHexToBytes(row.ciphertext),
nonce: pgHexToBytes(row.nonce),
});
}
return out;
}
// Map sender device id -> public key (for verifying envelope authenticity).
export async function fetchSenderDeviceKeys(
client: AppSupabaseClient,
deviceIds: string[],
): Promise<Map<string, Uint8Array>> {
if (deviceIds.length === 0) return new Map();
const unique = Array.from(new Set(deviceIds));
const { data, error } = await client
.from('devices')
.select('id, public_key')
.in('id', unique);
if (error) throw error;
const out = new Map<string, Uint8Array>();
for (const row of data ?? []) {
out.set(row.id, pgHexToBytes(row.public_key));
}
return out;
}
// ---------------------------------------------------------------------------
// Edit + delete
// ---------------------------------------------------------------------------
export interface EditMessageParams {
client: AppSupabaseClient;
messageId: string;
conversationId: string;
newPlaintext: string;
senderPrivateKey: Uint8Array;
}
// Re-encrypts the message for every currently-registered device in the
// conversation and rewrites the envelope rows. The server-side trigger
// enforces the 24h window + sender-only rule.
export async function editEncryptedMessage(params: EditMessageParams): Promise<void> {
const deviceKeys = await listConversationDeviceKeys(params.client, params.conversationId);
if (deviceKeys.length === 0) throw new Error('no recipient devices found');
const plainBytes = utf8ToBytes(params.newPlaintext);
const rows: {
message_id: string;
recipient_device_id: string;
ciphertext: string;
nonce: string;
}[] = [];
for (const dk of deviceKeys) {
const { ciphertext, nonce } = await encryptFor(plainBytes, dk.publicKey, params.senderPrivateKey);
rows.push({
message_id: params.messageId,
recipient_device_id: dk.deviceId,
ciphertext: bytesToPgHex(ciphertext),
nonce: bytesToPgHex(nonce),
});
}
// UPDATE the message row — trigger rechecks 24h window + sets edited_at.
const { error: mErr } = await params.client
.from('messages')
.update({ edited_at: new Date().toISOString() } as never)
.eq('id', params.messageId);
if (mErr) throw mErr;
// Upsert envelopes (INSERT on conflict UPDATE).
const { error: eErr } = await params.client
.from('message_envelopes')
.upsert(rows as never, { onConflict: 'message_id,recipient_device_id' });
if (eErr) throw eErr;
}
export async function softDeleteMessage(
client: AppSupabaseClient,
messageId: string,
): Promise<void> {
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<void> {
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<Set<string>> {
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));
}
// ---------------------------------------------------------------------------
// Reactions
// ---------------------------------------------------------------------------
export interface MessageReaction {
messageId: string;
userId: string;
emoji: string;
createdAt: string;
}
export async function listReactionsForMessages(
client: AppSupabaseClient,
messageIds: string[],
): Promise<MessageReaction[]> {
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<void> {
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<void> {
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 DecryptOptions {
ownPrivateKey: Uint8Array;
}
export async function decryptMessages(opts: {
messages: ChatMessage[];
envelopes: Map<string, { ciphertext: Uint8Array; nonce: Uint8Array }>;
senderKeys: Map<string, Uint8Array>;
ownPrivateKey: Uint8Array;
}): Promise<DecryptedMessage[]> {
const out: DecryptedMessage[] = [];
for (const m of opts.messages) {
const env = opts.envelopes.get(m.id);
const senderKey = m.senderDeviceId ? opts.senderKeys.get(m.senderDeviceId) : undefined;
let plaintext: string | null = null;
if (env && senderKey) {
try {
const decoded = await decryptFrom(env.ciphertext, env.nonce, senderKey, opts.ownPrivateKey);
plaintext = bytesToUtf8(decoded);
} catch {
plaintext = null;
}
}
out.push({ ...m, plaintext });
}
return out;
}