diff --git a/src/app/.well-known/synapsis/chat/[did]/route.ts b/src/app/.well-known/synapsis/chat/[did]/route.ts
index db0030d..6c3a814 100644
--- a/src/app/.well-known/synapsis/chat/[did]/route.ts
+++ b/src/app/.well-known/synapsis/chat/[did]/route.ts
@@ -40,8 +40,18 @@ export async function GET(
return NextResponse.json(response, {
headers: {
- 'Access-Control-Allow-Origin': '*', // Federation Header
- 'Cache-Control': 'max-age=60' // Cache for 1 min
+ 'Access-Control-Allow-Origin': '*',
+ 'Cache-Control': 'max-age=60'
}
});
}
+
+export async function OPTIONS() {
+ return new NextResponse(null, {
+ headers: {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'GET, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
+ },
+ });
+}
diff --git a/src/app/api/chat/keys/fetch/route.ts b/src/app/api/chat/keys/fetch/route.ts
new file mode 100644
index 0000000..0ffdc99
--- /dev/null
+++ b/src/app/api/chat/keys/fetch/route.ts
@@ -0,0 +1,56 @@
+
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function GET(request: NextRequest) {
+ const searchParams = request.nextUrl.searchParams;
+ const did = searchParams.get('did');
+ const nodeDomain = searchParams.get('nodeDomain');
+
+ if (!did) {
+ return NextResponse.json({ error: 'Missing DID' }, { status: 400 });
+ }
+
+ // Determine target URL
+ let bundleUrl = '';
+
+ // If did:web, extracting domain is easy
+ if (did.startsWith('did:web:')) {
+ const parts = did.split(':');
+ if (parts.length >= 4) {
+ const domain = parts[2];
+ const protocol = domain.includes('localhost') ? 'http' : 'https';
+ bundleUrl = `${protocol}://${domain}/.well-known/synapsis/chat/${did}`;
+ }
+ }
+
+ // If did:synapsis or did:web without built-in logic, check explicit domain
+ if (!bundleUrl && nodeDomain) {
+ const protocol = nodeDomain.includes('localhost') ? 'http' : 'https';
+ bundleUrl = `${protocol}://${nodeDomain}/.well-known/synapsis/chat/${did}`;
+ }
+
+ if (!bundleUrl) {
+ return NextResponse.json({ error: 'Cannot determine remote node URL. Missing nodeDomain?' }, { status: 400 });
+ }
+
+ try {
+ console.log(`[Proxy] Fetching keys from: ${bundleUrl}`);
+ const res = await fetch(bundleUrl, {
+ headers: {
+ 'Accept': 'application/json'
+ }
+ });
+
+ if (!res.ok) {
+ const text = await res.text();
+ console.error(`[Proxy] Remote fetch failed (${res.status}): ${text}`);
+ return NextResponse.json({ error: `Remote error: ${res.status}` }, { status: res.status });
+ }
+
+ const data = await res.json();
+ return NextResponse.json(data);
+ } catch (error: any) {
+ console.error('[Proxy] Fetch error:', error);
+ return NextResponse.json({ error: 'Failed to fetch remote keys' }, { status: 500 });
+ }
+}
diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx
index 6c37d77..36f9f9f 100644
--- a/src/app/chat/page.tsx
+++ b/src/app/chat/page.tsx
@@ -258,14 +258,27 @@ export default function ChatPage() {
// conversation.participant2 might have valid handle.
// We resolve DID first.
let did = selectedConversation.participant2.did;
+ // We need to support nodeDomain for existing chats too.
+ // But Conversation interface might lack it.
+ // We can try to resolve it from handle if needed, or if we stored it?
+ // "participant2" comes from API.
+ // Let's assume we re-fetch to be safe if it's remote?
+ // Or just check handle structure?
+ let nodeDomain = undefined;
+ if (selectedConversation.participant2.handle.includes('@')) {
+ const parts = selectedConversation.participant2.handle.split('@');
+ if (parts.length === 2) nodeDomain = parts[1];
+ }
+
if (!did) {
const res = await fetch(`/api/users/${encodeURIComponent(selectedConversation.participant2.handle)}`);
const data = await res.json();
did = data.user?.did;
+ nodeDomain = data.user?.nodeDomain || nodeDomain; // API is authoritative
if (!did) throw new Error('User not found');
}
- await sendMessage(did, newMessage);
+ await sendMessage(did, newMessage, nodeDomain);
// Legacy UI expects message reload.
setNewMessage('');
@@ -293,15 +306,20 @@ export default function ChatPage() {
}
// Send "Hello" to init session
- await sendMessage(data.user.did, '👋');
+ await sendMessage(data.user.did, '👋', data.user.nodeDomain);
setShowNewChat(false);
setNewChatHandle('');
loadConversations(false);
// Select the new conversation (we might need to find it)
// For now just reload list.
- } catch (e) {
- alert('Failed to start chat');
+ } catch (e: any) {
+ console.error('Start chat failed:', e);
+ if (e.message.includes('Recipient keys not found')) {
+ alert('This user has not set up secure chat yet. They need to log in to enable end-to-end encryption.');
+ } else {
+ alert('Failed to start chat: ' + e.message);
+ }
} finally { setSending(false); }
};
@@ -351,8 +369,27 @@ export default function ChatPage() {
);
}
+ // Error State
+ if (status === 'error') {
+ return (
+
+
+
Connection Failed
+
+ Unable to initialize secure chat. This might be a network issue or missing keys.
+
+
+
+ );
+ }
+
// Loading State
- if (status === 'initializing' || status === 'generating_keys') {
+ if ((!isReady && status !== 'error') || status === 'initializing' || status === 'generating_keys') {
return (
diff --git a/src/lib/crypto/user-signing.ts b/src/lib/crypto/user-signing.ts
index a2515f6..b2ea9b0 100644
--- a/src/lib/crypto/user-signing.ts
+++ b/src/lib/crypto/user-signing.ts
@@ -18,12 +18,15 @@ import { v4 as uuidv4 } from 'uuid';
export interface KeyStore {
setPrivateKey(key: CryptoKey): void;
getPrivateKey(): CryptoKey | null;
+ setIdentity(data: { did: string; handle: string; publicKey: string }): void;
+ getIdentity(): { did: string; handle: string; publicKey: string } | null;
clear(): void;
}
class InMemoryKeyStore implements KeyStore {
private static instance: InMemoryKeyStore;
private privateKey: CryptoKey | null = null;
+ private identity: { did: string; handle: string; publicKey: string } | null = null;
private constructor() { }
@@ -45,8 +48,17 @@ class InMemoryKeyStore implements KeyStore {
return this.privateKey;
}
+ setIdentity(data: { did: string; handle: string; publicKey: string }): void {
+ this.identity = data;
+ }
+
+ getIdentity() {
+ return this.identity;
+ }
+
clear(): void {
this.privateKey = null;
+ this.identity = null;
}
}
diff --git a/src/lib/hooks/useChatEncryption.ts b/src/lib/hooks/useChatEncryption.ts
index 4a3eaa4..f061421 100644
--- a/src/lib/hooks/useChatEncryption.ts
+++ b/src/lib/hooks/useChatEncryption.ts
@@ -55,13 +55,29 @@ export function useChatEncryption() {
if (unlocked) {
try {
- // If storage is open, verify keys exist
- // If so, we are ready.
- // If not, we might need ensureReady to generate, but usually they exist.
const keys = await loadDeviceKeys();
if (keys) {
setIsReady(true);
setStatus('ready');
+ } else if (status === 'idle' && identity?.did) {
+ // Keys missing but storage unlocked (and we have identity).
+ // Attempt to generate/restore keys.
+ console.log('[Chat] Storage unlocked but keys missing. Attempting generation...');
+ // We pass a placeholder password because storage is already unlocked.
+ // We need the user ID. We can extract it from the DID or if identity has 'id'?
+ // identity interface in useUserIdentity: { did, handle, publicKey, isUnlocked }
+ // It lacks 'id' (GUID).
+ // BUT ensureReady takes 'userId'.
+ // We used 'targetUser.id' in AuthContext.
+ // We might need to fetch 'me' or assume DID is enough?
+ // ensureReady uses userId for... unlockChatStorage(pass, userId).
+ // If unlocked, we don't use userId?
+ // Let's check ensureReady usage of userId. => It is passed to unlockChatStorage.
+ // If unlocked, it skips unlockChatStorage.
+ // So userId is ignored if unlocked. Safe to pass placeholder.
+ ensureReady('ALREADY_UNLOCKED', 'placeholder-user-id').catch(err => {
+ console.error('[Chat] Auto-generation failed:', err);
+ });
}
} catch (e) {
console.error("Auto-ready check failed", e);
@@ -72,7 +88,7 @@ export function useChatEncryption() {
check(); // Checks immediately
const interval = setInterval(check, 1000); // And polls
return () => clearInterval(interval);
- }, [isReady]);
+ }, [isReady, identity, status, signUserAction]);
// ... (ensureReady, sendMessage, decryptMessage) ...
@@ -138,13 +154,31 @@ export function useChatEncryption() {
}
}, [signUserAction]);
- const sendMessage = useCallback(async (recipientDid: string, content: string) => {
+ const sendMessage = useCallback(async (recipientDid: string, content: string, nodeDomain?: string) => {
if (!isReady || !identity) throw new Error('Chat not ready');
- // 1. Fetch Recipient Bundles
- const res = await fetch(`/.well-known/synapsis/chat/${recipientDid}`);
- if (!res.ok) throw new Error('Recipient not found');
- const bundles: any[] = await res.json();
+ // 1. Fetch Recipient Bundles (via Proxy to avoid CORS)
+ // We use our own server to fetch the keys from the remote node.
+ let proxyUrl = `/api/chat/keys/fetch?did=${encodeURIComponent(recipientDid)}`;
+ if (nodeDomain) {
+ proxyUrl += `&nodeDomain=${encodeURIComponent(nodeDomain)}`;
+ }
+
+ let bundles: any[];
+ try {
+ console.log(`[Chat] Fetching keys via proxy: ${proxyUrl}`);
+ const res = await fetch(proxyUrl);
+ if (!res.ok) {
+ const data = await res.json();
+ console.error(`[Chat] Bundle fetch failed (${res.status}):`, data);
+ throw new Error(`Recipient keys not found (Status: ${res.status})`);
+ }
+ bundles = await res.json();
+ } catch (err: any) {
+ console.error(`[Chat] Network error fetching bundles:`, err);
+ throw new Error(`Failed to resolve recipient keys: ${err.message}`);
+ }
+
const localDeviceId = localStorage.getItem('synapsis_device_id');
if (!localDeviceId) throw new Error('No local device ID');
diff --git a/src/lib/hooks/useUserIdentity.ts b/src/lib/hooks/useUserIdentity.ts
index 729547c..2a61a8f 100644
--- a/src/lib/hooks/useUserIdentity.ts
+++ b/src/lib/hooks/useUserIdentity.ts
@@ -34,12 +34,24 @@ export function useUserIdentity() {
const check = () => {
const hasKey = !!keyStore.getPrivateKey();
setIsUnlocked(hasKey);
- // We could also try to recover identity data if it's missing but key exists?
- // But identity data usually comes from initializeIdentity
+
+ // Auto-sync identity if available in singleton but missing in local state
+ const globalIdentity = keyStore.getIdentity();
+ if (globalIdentity) {
+ setIdentity(prev => {
+ // Avoid rerenders if same
+ if (prev && prev.did === globalIdentity.did && prev.isUnlocked === hasKey) return prev;
+ return { ...globalIdentity, isUnlocked: hasKey };
+ });
+ } else {
+ // If global cleared, clear local
+ setIdentity(prev => prev ? null : null);
+ }
};
check();
- const interval = setInterval(check, 1000);
+ // Poll fast to ensure UI updates are snappy
+ const interval = setInterval(check, 500);
return () => clearInterval(interval);
}, []);
@@ -53,15 +65,22 @@ export function useUserIdentity() {
privateKeyEncrypted: string;
}, password?: string) => {
+ // If password provided, attempt unlock
+ // Save to singleton
+ const coreIdentity = {
+ did: userData.did,
+ handle: userData.handle,
+ publicKey: userData.publicKey
+ };
+ keyStore.setIdentity(coreIdentity);
+
// If password provided, attempt unlock
if (password) {
await unlockIdentity(userData.privateKeyEncrypted, password);
} else {
// Just set public identity info if locked
setIdentity({
- did: userData.did,
- handle: userData.handle,
- publicKey: userData.publicKey,
+ ...coreIdentity,
isUnlocked: !!keyStore.getPrivateKey()
});
}
@@ -131,10 +150,16 @@ export function useUserIdentity() {
* Sign a user action
*/
const signUserAction = async (action: string, data: any) => {
- if (!identity || !isUnlocked) {
+ // Re-check global state directly to be safe
+ const pk = keyStore.getPrivateKey();
+ const id = keyStore.getIdentity();
+
+ if (!id || !pk) {
+ console.error('[Identity] Sign failed. Identity:', id, 'HasKey:', !!pk);
throw new Error('Identity locked');
}
- return await createSignedAction(action, data, identity.did, identity.handle);
+ // Use the fetched identity to ensure sync
+ return await createSignedAction(action, data, id.did, id.handle);
};
return {