Compare commits

...

5 Commits

Author SHA1 Message Date
byGalax d9b08592da fix(messages): refetch row via REST on realtime insert + optimistic sender update
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:35:00 +02:00
byGalax 4a80bf1c0e fix(realtime): handle base64 bytea payloads from postgres_changes
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:19:40 +02:00
byGalax 05c962d46f fix(auth): scope signOut to local session only
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:11:08 +02:00
byGalax cf3fef6936 fix(secretStore): fall back to localStorage when stronghold init fails
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:05:37 +02:00
byGalax 8c878b3718 fix(capabilities): allow stronghold operations
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:02:44 +02:00
7 changed files with 135 additions and 20 deletions
@@ -15,6 +15,14 @@
"updater:allow-check", "updater:allow-check",
"updater:allow-download", "updater:allow-download",
"updater:allow-install", "updater:allow-install",
"updater:allow-download-and-install" "updater:allow-download-and-install",
"stronghold:default",
"stronghold:allow-initialize",
"stronghold:allow-load-client",
"stronghold:allow-create-client",
"stronghold:allow-save",
"stronghold:allow-get-store-record",
"stronghold:allow-save-store-record",
"stronghold:allow-remove-store-record"
] ]
} }
+1 -1
View File
@@ -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.1", "version": "0.3.6",
"identifier": "com.meinname.chatapp", "identifier": "com.meinname.chatapp",
"build": { "build": {
"beforeDevCommand": "pnpm vite:dev", "beforeDevCommand": "pnpm vite:dev",
+1 -1
View File
@@ -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);
+13 -3
View File
@@ -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;
+89 -13
View File
@@ -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(
@@ -219,7 +274,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 +285,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);
+5 -1
View File
@@ -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;
} }
+17
View File
@@ -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;
}