Files
ChatApp/packages/shared/src/chat/messages.ts
T
byGalax c9fe4879e0 fix(shared): stop sending fake install-id as messages.sender_device_id
Task 12 (the AuthContext userKeyState refactor) replaced the per-device
DeviceRecord lookup with a localStorage UUID via ensureInstallId(). That
UUID was then passed straight through to messages.sender_device_id on
INSERT.

The messages_insert_member RLS policy requires sender_device_id to be
NULL OR to match a row in `devices` owned by the caller. The localStorage
UUID matches neither -> 403 -> outbox endlessly retries with "Wiederhole".

Fix: SendMessageParams.senderDeviceId becomes optional, and the message
INSERT coerces undefined to NULL. The column is pure telemetry post-conv-
keys so passing NULL is correct. Existing call sites that hand in
ensureInstallId() still typecheck (string is assignable to string|null|undefined)
but the row is written with NULL until those callers stop passing it.
2026-05-16 00:59:28 +02:00

542 lines
18 KiB
TypeScript

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<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 ?? [])
.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;
// Optional now: post-conv-keys this is pure telemetry. The 0.18 builds
// started passing a localStorage UUID that doesn't exist in the devices
// table; messages.sender_device_id RLS then 403'd every insert. Senders
// pass null (or an actually-registered device id, if they have one).
senderDeviceId?: string | null;
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<ChatMessage> {
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<string, unknown> = {
conversation_id: params.conversationId,
sender_id: params.senderUserId,
// ALWAYS null until we re-introduce a real per-install devices row.
// Desktop callers currently pass a localStorage UUID (ensureInstallId)
// which doesn't exist in the devices table; the messages_insert_member
// RLS policy then 403s because the id can't be proven to belong to the
// caller. NULL satisfies the policy ("sender_device_id IS NULL OR …").
sender_device_id: null,
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<MessageWithCipher[]> {
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 | null },
): Promise<void> {
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<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));
}
// 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<Map<string, Map<string, string>>> {
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<string, Map<string, string>>();
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<Map<string, Map<string, string>>> {
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<string, Map<string, string>>();
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<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 }));
// `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<Set<string>> {
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<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 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<Array<{ id: string; plaintext: string | null }>>;
}
// 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<DecryptedMessage[]> {
const versions = new Map<string, Map<number, Uint8Array | null>>(); // 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<string, string | null>();
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 };
});
}