Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d20c7e210b | |||
| 58fa9487e3 | |||
| d9b08592da | |||
| 4a80bf1c0e | |||
| 05c962d46f | |||
| cf3fef6936 |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ChatApp",
|
"productName": "ChatApp",
|
||||||
"version": "0.3.2",
|
"version": "0.3.8",
|
||||||
"identifier": "com.meinname.chatapp",
|
"identifier": "com.meinname.chatapp",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm vite:dev",
|
"beforeDevCommand": "pnpm vite:dev",
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const status = (error as { status?: number }).status;
|
const status = (error as { status?: number }).status;
|
||||||
if (status === 401 || status === 403) {
|
if (status === 401 || status === 403) {
|
||||||
// Token genuinely invalid — wipe.
|
// Token genuinely invalid — wipe.
|
||||||
await supabase.auth.signOut().catch(() => {
|
await supabase.auth.signOut({ scope: 'local' }).catch(() => {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
});
|
});
|
||||||
setSession(null);
|
setSession(null);
|
||||||
|
|||||||
@@ -5,16 +5,24 @@ import { pgHexToBytes } from '@chat-app/shared/supabase';
|
|||||||
import { devLocalSecretStore } from './secretStore';
|
import { devLocalSecretStore } from './secretStore';
|
||||||
import { supabase } from './supabase';
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
// Watches the `devices` table for INSERTs and, whenever a peer registers a
|
// Watches the `devices` table for new entries AND, on mount, scans every
|
||||||
// new device that's in any of our conversations, wraps the active
|
// conversation we participate in for missing key bundles. Fills gaps by
|
||||||
// conversation key for the freshly-arrived device. This makes Sender-Key
|
// re-wrapping our active conv-key for the missing recipient devices.
|
||||||
// onboarding "just work" — the new device picks up the bundle from
|
|
||||||
// `conversation_keys` and can decrypt the entire history once at least one
|
|
||||||
// of our existing devices was online to do the wrapping.
|
|
||||||
//
|
//
|
||||||
// At-least-once delivery: if no existing device of any participant is online
|
// This fixes the "cannot decrypt" cliff for devices that registered while
|
||||||
// at the moment the new device joins, the new device stays unable to decrypt
|
// no other participant device was online to share the key with them.
|
||||||
// until SOMEONE comes online and runs this loop. Standard Signal trade-off.
|
|
||||||
|
interface SyncCtx {
|
||||||
|
myUserId: string;
|
||||||
|
myDeviceId: string;
|
||||||
|
priv: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
// db-types is stale for `conversation_keys`/`active_key_version`; bypass.
|
||||||
|
function rawFrom(table: string) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return (supabase as unknown as { from: (t: string) => any }).from(table);
|
||||||
|
}
|
||||||
|
|
||||||
export function startConversationKeySync(
|
export function startConversationKeySync(
|
||||||
ownUserId: string,
|
ownUserId: string,
|
||||||
@@ -23,8 +31,13 @@ export function startConversationKeySync(
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let priv: Uint8Array | null = null;
|
let priv: Uint8Array | null = null;
|
||||||
|
|
||||||
void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then((pk) => {
|
void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then(async (pk) => {
|
||||||
|
if (cancelled) return;
|
||||||
priv = pk;
|
priv = pk;
|
||||||
|
if (!priv) return;
|
||||||
|
// Run a full backfill once we have the private key — covers devices that
|
||||||
|
// registered while we were offline.
|
||||||
|
await syncAllExistingGaps({ myUserId: ownUserId, myDeviceId: ownDeviceId, priv });
|
||||||
});
|
});
|
||||||
|
|
||||||
const channel = supabase
|
const channel = supabase
|
||||||
@@ -36,73 +49,164 @@ export function startConversationKeySync(
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const row = payload.new;
|
const row = payload.new;
|
||||||
if (!row?.id || !row.user_id || !row.public_key) return;
|
if (!row?.id || !row.user_id || !row.public_key) return;
|
||||||
// Skip our own devices — we don't need to send keys to ourselves
|
|
||||||
// (each install bootstraps its own keys via getOrCreateConvKey).
|
|
||||||
if (row.user_id === ownUserId && row.id === ownDeviceId) return;
|
if (row.user_id === ownUserId && row.id === ownDeviceId) return;
|
||||||
void wrapKeysForNewDevice(ownUserId, ownDeviceId, row.id, row.user_id, row.public_key);
|
if (!priv) return; // backfill on mount will catch it later
|
||||||
|
void wrapForOneDevice(
|
||||||
|
{ myUserId: ownUserId, myDeviceId: ownDeviceId, priv },
|
||||||
|
row.id,
|
||||||
|
row.user_id,
|
||||||
|
row.public_key,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
|
|
||||||
async function wrapKeysForNewDevice(
|
|
||||||
myUserId: string,
|
|
||||||
myDeviceId: string,
|
|
||||||
newDeviceId: string,
|
|
||||||
newDeviceUserId: string,
|
|
||||||
newDevicePubKeyHex: string,
|
|
||||||
) {
|
|
||||||
if (!priv) {
|
|
||||||
priv = await loadDevicePrivateKey(devLocalSecretStore, myUserId, myDeviceId);
|
|
||||||
if (!priv) return;
|
|
||||||
}
|
|
||||||
const newPub = pgHexToBytes(newDevicePubKeyHex);
|
|
||||||
const ownCtx: OwnDeviceCtx = {
|
|
||||||
userId: myUserId,
|
|
||||||
deviceId: myDeviceId,
|
|
||||||
privateKey: priv,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Find conversations I'm in that the new device's user is also in.
|
|
||||||
const { data: shared, error: sErr } = await supabase
|
|
||||||
.from('conversation_members')
|
|
||||||
.select('conversation_id')
|
|
||||||
.eq('user_id', newDeviceUserId);
|
|
||||||
if (sErr) {
|
|
||||||
console.warn('keySync member lookup failed', sErr);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const peerConvs = new Set((shared ?? []).map((r) => r.conversation_id as string));
|
|
||||||
if (peerConvs.size === 0) return;
|
|
||||||
|
|
||||||
const { data: mine, error: mErr } = await supabase
|
|
||||||
.from('conversation_members')
|
|
||||||
.select('conversation_id')
|
|
||||||
.eq('user_id', myUserId)
|
|
||||||
.eq('accepted', true);
|
|
||||||
if (mErr) {
|
|
||||||
console.warn('keySync own-member lookup failed', mErr);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targets: string[] = [];
|
|
||||||
for (const row of mine ?? []) {
|
|
||||||
const id = row.conversation_id as string;
|
|
||||||
if (peerConvs.has(id)) targets.push(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const convId of targets) {
|
|
||||||
try {
|
|
||||||
await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
// Common: this device has no key for that conv yet (was offline at
|
|
||||||
// bootstrap). Other online devices will handle it.
|
|
||||||
console.warn('shareConvKeyToDevice failed', { convId, err });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
void supabase.removeChannel(channel);
|
void supabase.removeChannel(channel);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listMyConversationIds(myUserId: string): Promise<string[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('conversation_members')
|
||||||
|
.select('conversation_id')
|
||||||
|
.eq('user_id', myUserId)
|
||||||
|
.eq('accepted', true);
|
||||||
|
if (error) {
|
||||||
|
console.warn('keySync: own-member lookup failed', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return (data ?? []).map((r) => r.conversation_id as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listConversationDevices(
|
||||||
|
conversationId: string,
|
||||||
|
): Promise<{ id: string; user_id: string; public_key: string }[]> {
|
||||||
|
const { data: members, error: mErr } = await supabase
|
||||||
|
.from('conversation_members')
|
||||||
|
.select('user_id')
|
||||||
|
.eq('conversation_id', conversationId)
|
||||||
|
.eq('accepted', true);
|
||||||
|
if (mErr) {
|
||||||
|
console.warn('keySync: members lookup failed', mErr);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const userIds = (members ?? []).map((m) => m.user_id as string);
|
||||||
|
if (userIds.length === 0) return [];
|
||||||
|
|
||||||
|
const { data: devices, error: dErr } = await supabase
|
||||||
|
.from('devices')
|
||||||
|
.select('id, user_id, public_key')
|
||||||
|
.in('user_id', userIds);
|
||||||
|
if (dErr) {
|
||||||
|
console.warn('keySync: devices lookup failed', dErr);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return (devices ?? []) as { id: string; user_id: string; public_key: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listExistingKeyRecipients(
|
||||||
|
conversationId: string,
|
||||||
|
keyVersion: number,
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
const { data, error } = await rawFrom('conversation_keys')
|
||||||
|
.select('recipient_device_id')
|
||||||
|
.eq('conversation_id', conversationId)
|
||||||
|
.eq('key_version', keyVersion);
|
||||||
|
if (error) {
|
||||||
|
console.warn('keySync: existing keys lookup failed', error);
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
return new Set((data ?? []).map((r: { recipient_device_id: string }) => r.recipient_device_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getActiveKeyVersion(conversationId: string): Promise<number> {
|
||||||
|
const { data, error } = await rawFrom('conversations')
|
||||||
|
.select('active_key_version')
|
||||||
|
.eq('id', conversationId)
|
||||||
|
.single();
|
||||||
|
if (error) {
|
||||||
|
console.warn('keySync: active key version lookup failed', error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return (data as { active_key_version: number }).active_key_version;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAllExistingGaps(ctx: SyncCtx): Promise<void> {
|
||||||
|
const convs = await listMyConversationIds(ctx.myUserId);
|
||||||
|
for (const convId of convs) {
|
||||||
|
try {
|
||||||
|
await syncOneConversationGaps(ctx, convId);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('keySync: conv gap sync failed', { convId, err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<void> {
|
||||||
|
const version = await getActiveKeyVersion(convId);
|
||||||
|
const devices = await listConversationDevices(convId);
|
||||||
|
if (devices.length === 0) return;
|
||||||
|
|
||||||
|
const recipients = await listExistingKeyRecipients(convId, version);
|
||||||
|
const ownCtx: OwnDeviceCtx = {
|
||||||
|
userId: ctx.myUserId,
|
||||||
|
deviceId: ctx.myDeviceId,
|
||||||
|
privateKey: ctx.priv,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const dev of devices) {
|
||||||
|
if (recipients.has(dev.id)) continue;
|
||||||
|
// Skip our own device — we already have the bundle if we're capable of
|
||||||
|
// sharing (or don't need it if we ourselves haven't been wrapped yet).
|
||||||
|
if (dev.id === ctx.myDeviceId) continue;
|
||||||
|
try {
|
||||||
|
await shareConvKeyToDevice(supabase, convId, dev.id, pgHexToBytes(dev.public_key), ownCtx);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Most common: this device hasn't been wrapped for us yet either, so
|
||||||
|
// tryGetConvKey couldn't unwrap. Another peer with the key will fill
|
||||||
|
// the gap when they hit syncAllExistingGaps.
|
||||||
|
console.warn('keySync: shareConvKeyToDevice gap-fill failed', {
|
||||||
|
convId,
|
||||||
|
recipient: dev.id,
|
||||||
|
err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wrapForOneDevice(
|
||||||
|
ctx: SyncCtx,
|
||||||
|
newDeviceId: string,
|
||||||
|
newDeviceUserId: string,
|
||||||
|
newDevicePubHex: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const myConvs = new Set(await listMyConversationIds(ctx.myUserId));
|
||||||
|
const { data: peerMember, error: pErr } = await supabase
|
||||||
|
.from('conversation_members')
|
||||||
|
.select('conversation_id')
|
||||||
|
.eq('user_id', newDeviceUserId);
|
||||||
|
if (pErr) {
|
||||||
|
console.warn('keySync: peer-member lookup failed', pErr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sharedConvs = (peerMember ?? [])
|
||||||
|
.map((r) => r.conversation_id as string)
|
||||||
|
.filter((id) => myConvs.has(id));
|
||||||
|
if (sharedConvs.length === 0) return;
|
||||||
|
|
||||||
|
const newPub = pgHexToBytes(newDevicePubHex);
|
||||||
|
const ownCtx: OwnDeviceCtx = {
|
||||||
|
userId: ctx.myUserId,
|
||||||
|
deviceId: ctx.myDeviceId,
|
||||||
|
privateKey: ctx.priv,
|
||||||
|
};
|
||||||
|
for (const convId of sharedConvs) {
|
||||||
|
try {
|
||||||
|
await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('keySync: shareConvKeyToDevice failed', { convId, err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,11 +39,21 @@ export async function setSecretStoreUser(userId: string | null): Promise<void> {
|
|||||||
|
|
||||||
if (userId && isTauriRuntime()) {
|
if (userId && isTauriRuntime()) {
|
||||||
const stronghold = makeStrongholdStore(userId);
|
const stronghold = makeStrongholdStore(userId);
|
||||||
activeBackend = stronghold;
|
|
||||||
try {
|
try {
|
||||||
await migrateLocalStorageToStronghold(userId, PREFIX);
|
// Force a tiny round-trip to verify Stronghold can actually open the
|
||||||
|
// vault on this machine. If not (broken vault file, bundled rust crate
|
||||||
|
// mismatch, etc.) we fall back to localStorage so the rest of the app
|
||||||
|
// remains usable instead of bricking device registration.
|
||||||
|
await stronghold.getSecret('__probe');
|
||||||
|
activeBackend = stronghold;
|
||||||
|
try {
|
||||||
|
await migrateLocalStorageToStronghold(userId, PREFIX);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('stronghold migration failed', err);
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.warn('stronghold migration failed', err);
|
console.warn('stronghold init failed — falling back to localStorage', err);
|
||||||
|
activeBackend = localStore;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
activeBackend = localStore;
|
activeBackend = localStore;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
type MessageWithCipher,
|
type MessageWithCipher,
|
||||||
sendEncryptedMessage,
|
sendEncryptedMessage,
|
||||||
} from '@chat-app/shared/chat';
|
} from '@chat-app/shared/chat';
|
||||||
import { bytesToPgHex, pgHexToBytes } from '@chat-app/shared/supabase';
|
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { devLocalSecretStore } from './secretStore';
|
import { devLocalSecretStore } from './secretStore';
|
||||||
@@ -44,8 +44,8 @@ function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
|
|||||||
editedAt: row.edited_at ? String(row.edited_at) : null,
|
editedAt: row.edited_at ? String(row.edited_at) : null,
|
||||||
deletedAt: row.deleted_at ? String(row.deleted_at) : null,
|
deletedAt: row.deleted_at ? String(row.deleted_at) : null,
|
||||||
createdAt: String(row.created_at),
|
createdAt: String(row.created_at),
|
||||||
ciphertext: pgHexToBytes(String(row.ciphertext ?? '\\x')),
|
ciphertext: pgBytesToBytes(String(row.ciphertext ?? '\\x')),
|
||||||
nonce: pgHexToBytes(String(row.nonce ?? '\\x')),
|
nonce: pgBytesToBytes(String(row.nonce ?? '\\x')),
|
||||||
keyVersion: typeof row.key_version === 'number' ? row.key_version : 1,
|
keyVersion: typeof row.key_version === 'number' ? row.key_version : 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -98,26 +98,81 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
}
|
}
|
||||||
}, [conversationId, decryptBatch]);
|
}, [conversationId, decryptBatch]);
|
||||||
|
|
||||||
// Realtime INSERT handler — decrypt + append (with retry for envelope race).
|
// Realtime INSERT handler — refetches the row via REST so we get the
|
||||||
|
// canonical bytea encoding (postgres_changes payloads serialize bytea
|
||||||
|
// differently and decoding them inline is brittle). Then decrypt + append.
|
||||||
|
// Skips if the message is already in state (e.g. optimistic insert from our
|
||||||
|
// own send), so the sender's cached copy isn't overwritten with a flicker.
|
||||||
const handleInsert = useCallback(
|
const handleInsert = useCallback(
|
||||||
async (row: Record<string, unknown>) => {
|
async (row: Record<string, unknown>) => {
|
||||||
if (!deviceId) return;
|
if (!conversationId || !deviceId) return;
|
||||||
const msg = rowToMessage(row);
|
const id = String(row.id);
|
||||||
let decrypted: DecryptedMessage = { ...msg, plaintext: null };
|
|
||||||
|
let alreadyHave = false;
|
||||||
|
setState((prev) => {
|
||||||
|
if (prev.messages.some((m) => m.id === id)) alreadyHave = true;
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
if (alreadyHave) return;
|
||||||
|
|
||||||
|
let decrypted: DecryptedMessage | null = null;
|
||||||
for (let attempt = 0; attempt < 6; attempt++) {
|
for (let attempt = 0; attempt < 6; attempt++) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('messages')
|
||||||
|
.select(
|
||||||
|
'id, conversation_id, sender_id, sender_device_id, reply_to_id, edited_at, deleted_at, created_at, ciphertext, nonce, key_version',
|
||||||
|
)
|
||||||
|
.eq('id', id)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error) {
|
||||||
|
console.warn('handleInsert refetch failed', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
await new Promise((r) => window.setTimeout(r, 120 * (attempt + 1)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// db-types snapshot predates the sender-key columns; cast to bypass.
|
||||||
|
const r = data as unknown as {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
const msg: MessageWithCipher = {
|
||||||
|
id: r.id,
|
||||||
|
conversationId: r.conversation_id,
|
||||||
|
senderId: r.sender_id,
|
||||||
|
senderDeviceId: r.sender_device_id,
|
||||||
|
replyToId: r.reply_to_id,
|
||||||
|
editedAt: r.edited_at,
|
||||||
|
deletedAt: r.deleted_at,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
ciphertext: pgBytesToBytes(String(r.ciphertext)),
|
||||||
|
nonce: pgBytesToBytes(String(r.nonce)),
|
||||||
|
keyVersion: r.key_version,
|
||||||
|
};
|
||||||
const [d] = await decryptBatch([msg]);
|
const [d] = await decryptBatch([msg]);
|
||||||
if (d) {
|
if (d) {
|
||||||
decrypted = d;
|
decrypted = d;
|
||||||
if (d.plaintext !== null) break;
|
if (d.plaintext !== null) break;
|
||||||
}
|
}
|
||||||
await new Promise((r) => window.setTimeout(r, 120 * (attempt + 1)));
|
await new Promise((r) => window.setTimeout(r, 200 * (attempt + 1)));
|
||||||
}
|
}
|
||||||
|
if (!decrypted) return;
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
if (prev.messages.some((m) => m.id === decrypted.id)) return prev;
|
if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
|
||||||
return { ...prev, messages: [...prev.messages, decrypted] };
|
return { ...prev, messages: [...prev.messages, decrypted!] };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[deviceId, decryptBatch],
|
[conversationId, deviceId, decryptBatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUpdate = useCallback(
|
const handleUpdate = useCallback(
|
||||||
@@ -183,6 +238,22 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
// When a peer device wraps the conversation-key for us (e.g. we just
|
||||||
|
// registered a fresh device), re-decrypt the visible messages.
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{
|
||||||
|
event: 'INSERT',
|
||||||
|
schema: 'public',
|
||||||
|
table: 'conversation_keys',
|
||||||
|
filter: 'conversation_id=eq.' + conversationId,
|
||||||
|
},
|
||||||
|
(payload: { new: { recipient_device_id?: string } }) => {
|
||||||
|
if (payload.new?.recipient_device_id === deviceId) {
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -219,7 +290,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce));
|
blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Send message (inserts messages + envelopes in one helper).
|
// 2. Send message (inserts messages + per-conversation key bundles).
|
||||||
const msg = await sendEncryptedMessage({
|
const msg = await sendEncryptedMessage({
|
||||||
client: supabase,
|
client: supabase,
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -230,7 +301,28 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
...(handles.length > 0 ? { attachmentHandles: handles } : {}),
|
...(handles.length > 0 ? { attachmentHandles: handles } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Insert public attachment metadata rows pointing at the new message.
|
// 3. Optimistic insert — we already have the plaintext in hand and the
|
||||||
|
// server returned the row id, so add the message to local state
|
||||||
|
// immediately. Realtime will then no-op (handleInsert dedupes by id).
|
||||||
|
const attachmentsPayload =
|
||||||
|
handles.length === 0
|
||||||
|
? trimmed
|
||||||
|
: JSON.stringify({ v: 1, text: trimmed, attachments: handles });
|
||||||
|
setState((prev) => {
|
||||||
|
if (prev.messages.some((m) => m.id === msg.id)) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
messages: [
|
||||||
|
...prev.messages,
|
||||||
|
{
|
||||||
|
...msg,
|
||||||
|
plaintext: attachmentsPayload,
|
||||||
|
} as DecryptedMessage,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Insert public attachment metadata rows pointing at the new message.
|
||||||
for (const h of handles) {
|
for (const h of handles) {
|
||||||
const blobNonce = blobNonceHexByHandleId.get(h.id) ?? '\\x';
|
const blobNonce = blobNonceHexByHandleId.get(h.id) ?? '\\x';
|
||||||
await insertAttachmentRow(supabase, msg.id, h, blobNonce);
|
await insertAttachmentRow(supabase, msg.id, h, blobNonce);
|
||||||
|
|||||||
@@ -86,7 +86,11 @@ export async function completeSessionFromUrl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function signOut(client: AppSupabaseClient): Promise<void> {
|
export async function signOut(client: AppSupabaseClient): Promise<void> {
|
||||||
const { error } = await client.auth.signOut();
|
// `scope: 'local'` only ends the session in THIS client. Without it Supabase
|
||||||
|
// defaults to 'global', which invalidates the user's refresh tokens
|
||||||
|
// everywhere — meaning a logout in the browser would also kick the desktop
|
||||||
|
// app (and vice versa) the next time it tries to refresh its token.
|
||||||
|
const { error } = await client.auth.signOut({ scope: 'local' });
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,3 +23,20 @@ export function pgHexToBytes(hex: string): Uint8Array {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accepts either:
|
||||||
|
// - PostgREST/REST `\x<hex>` strings (what the .from('table').select() path
|
||||||
|
// returns for bytea), or
|
||||||
|
// - Realtime `postgres_changes` payloads, which encode bytea as plain
|
||||||
|
// base64 (no `\x` prefix).
|
||||||
|
// Useful when the same row can arrive through both paths in the same UI.
|
||||||
|
export function pgBytesToBytes(value: string): Uint8Array {
|
||||||
|
if (value.startsWith('\\x')) {
|
||||||
|
return pgHexToBytes(value);
|
||||||
|
}
|
||||||
|
// Assume base64 (the realtime serializer's default for bytea).
|
||||||
|
const bin = atob(value);
|
||||||
|
const out = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,20 @@ import type { Database, SupabaseConfig } from './types.js';
|
|||||||
// Typed client alias used throughout the app.
|
// Typed client alias used throughout the app.
|
||||||
export type AppSupabaseClient = SupabaseClient<Database>;
|
export type AppSupabaseClient = SupabaseClient<Database>;
|
||||||
|
|
||||||
|
// Inline serial lock — replaces Supabase's default `navigator.locks` based
|
||||||
|
// lock that occasionally throws "Lock was stolen by another request" when
|
||||||
|
// the same origin opens multiple tabs / Tauri windows / HMR-reloaded
|
||||||
|
// modules. We only have one client instance per process so a simple promise
|
||||||
|
// chain serialises token-refresh fine without cross-tab coordination.
|
||||||
|
const acquireLock = (() => {
|
||||||
|
let chain: Promise<unknown> = Promise.resolve();
|
||||||
|
return async <R>(_name: string, _acquireTimeout: number, fn: () => Promise<R>): Promise<R> => {
|
||||||
|
const next = chain.then(() => fn(), () => fn());
|
||||||
|
chain = next.catch(() => undefined);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
export function createClient(config: SupabaseConfig): AppSupabaseClient {
|
export function createClient(config: SupabaseConfig): AppSupabaseClient {
|
||||||
return createSupabaseClient<Database>(config.url, config.anonKey, {
|
return createSupabaseClient<Database>(config.url, config.anonKey, {
|
||||||
auth: {
|
auth: {
|
||||||
@@ -12,6 +26,7 @@ export function createClient(config: SupabaseConfig): AppSupabaseClient {
|
|||||||
autoRefreshToken: true,
|
autoRefreshToken: true,
|
||||||
persistSession: true,
|
persistSession: true,
|
||||||
detectSessionInUrl: config.detectSessionInUrl ?? false,
|
detectSessionInUrl: config.detectSessionInUrl ?? false,
|
||||||
|
lock: acquireLock,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user