From a21af8714411f0b95189a79c8aad287e73f2fa7e Mon Sep 17 00:00:00 2001 From: Christopher Date: Wed, 28 Jan 2026 07:27:44 -0800 Subject: [PATCH] Implement federated chat message sending and receiving Adds /api/chat/receive endpoint to accept federated chat messages from remote nodes and store them for local users. Updates /api/chat/send to support sending messages to remote nodes by resolving recipient domains, posting to their /api/chat/receive endpoint, and storing a local copy of the sent message. Enables basic cross-node chat federation. --- src/app/api/chat/receive/route.ts | 94 +++++++++++++++++++++++++++++++ src/app/api/chat/send/route.ts | 91 ++++++++++++++++++++++++++++-- 2 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 src/app/api/chat/receive/route.ts diff --git a/src/app/api/chat/receive/route.ts b/src/app/api/chat/receive/route.ts new file mode 100644 index 0000000..9edcf9d --- /dev/null +++ b/src/app/api/chat/receive/route.ts @@ -0,0 +1,94 @@ + +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/db'; +import { chatConversations, chatMessages, users } from '@/db/schema'; +import { eq, and } from 'drizzle-orm'; + +/** + * POST /api/chat/receive + * Endpoint for receiving federated chat messages from other nodes. + * + * Body: { + * senderDid: string, + * senderHandle: string, // user@domain + * senderDisplayName: string, + * senderAvatarUrl: string, + * senderNodeDomain: string, + * recipientDid: string, + * encryptedContent: string (base64 JSON of {senderPublicKey, ciphertext, nonce, recipientDid}), + * sentAt: string (ISO date) + * } + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { + senderDid, + senderHandle, + senderDisplayName, + senderAvatarUrl, + senderNodeDomain, + recipientDid, + encryptedContent + } = body; + + // Basic validation + if (!senderDid || !senderHandle || !recipientDid || !encryptedContent || !senderNodeDomain) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + // 1. Find local recipient + const recipientUser = await db.query.users.findFirst({ + where: eq(users.did, recipientDid) + }); + + if (!recipientUser) { + return NextResponse.json({ error: 'Recipient not found' }, { status: 404 }); + } + + // 2. Find or Create Conversation + // For the RECIPIENT, the conversation is with the SENDER (Remote) + let conversation = await db.query.chatConversations.findFirst({ + where: and( + eq(chatConversations.participant1Id, recipientUser.id), + eq(chatConversations.participant2Handle, senderHandle) + ) + }); + + if (!conversation) { + const [newConv] = await db.insert(chatConversations).values({ + participant1Id: recipientUser.id, + participant2Handle: senderHandle, + lastMessageAt: new Date(), + lastMessagePreview: '[Encrypted Message]' + }).returning(); + conversation = newConv; + } + + // 3. Store Message + await db.insert(chatMessages).values({ + conversationId: conversation.id, + senderHandle: senderHandle, + senderDisplayName: senderDisplayName || senderHandle, + senderAvatarUrl: senderAvatarUrl, + senderNodeDomain: senderNodeDomain, + senderDid: senderDid, + encryptedContent: encryptedContent, + deliveredAt: new Date(), + }); + + // 4. Update conversation timestamp + await db.update(chatConversations) + .set({ + lastMessageAt: new Date(), + lastMessagePreview: '[Encrypted Message]' + }) + .where(eq(chatConversations.id, conversation.id)); + + return NextResponse.json({ success: true }); + + } catch (error: any) { + console.error('Federated Receive Failed:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 2eef2a6..72756e6 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/db'; -import { chatConversations, chatMessages, users } from '@/db/schema'; +import { chatConversations, chatMessages, users, handleRegistry } from '@/db/schema'; import { requireAuth } from '@/lib/auth'; import { eq, and } from 'drizzle-orm'; @@ -34,7 +34,7 @@ export async function POST(request: NextRequest) { if (recipientUser) { // LOCAL RECIPIENT - + // Ensure conversations exist let recipientConv = await db.query.chatConversations.findFirst({ where: and( @@ -105,8 +105,91 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true }); } else { - // REMOTE RECIPIENT - not implemented yet - return NextResponse.json({ error: 'Remote delivery not yet implemented' }, { status: 501 }); + // REMOTE RECIPIENT + const { handleRegistry } = await import('@/db/schema'); // dynamic import or add to top + + // 1. Resolve recipient node + const registryEntry = await db.query.handleRegistry.findFirst({ + where: eq(handleRegistry.did, recipientDid) + }); + + if (!registryEntry) { + return NextResponse.json({ error: 'Recipient not found in registry' }, { status: 404 }); + } + + const targetDomain = registryEntry.nodeDomain; + const targetHandle = registryEntry.handle; // e.g. user@domain + + // 2. Prepare Payload + const messageData = { + senderPublicKey, + recipientDid, + ciphertext, + nonce, + }; + const encryptedContent = JSON.stringify(messageData); + + const payload = { + senderDid: user.did, + senderHandle: user.handle, + senderDisplayName: user.displayName, + senderAvatarUrl: user.avatarUrl, + senderNodeDomain: process.env.NEXT_PUBLIC_NODE_DOMAIN || 'dev.syn.quest', // Current node domain + recipientDid, + encryptedContent, + sentAt: new Date().toISOString() + }; + + // 3. Send to Remote Node + try { + const protocol = targetDomain.includes('localhost') ? 'http' : 'https'; + const res = await fetch(`${protocol}://${targetDomain}/api/chat/receive`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!res.ok) { + const errText = await res.text(); + console.error('Remote node rejected chat:', errText); + return NextResponse.json({ error: `Remote delivery failed: ${res.statusText}` }, { status: 502 }); + } + } catch (err: any) { + console.error('Failed to contact remote node:', err); + return NextResponse.json({ error: 'Failed to contact remote node' }, { status: 504 }); + } + + // 4. Store "Sent" copy locally + // Ensure conversation exists locally + let senderConv = await db.query.chatConversations.findFirst({ + where: and( + eq(chatConversations.participant1Id, user.id), + eq(chatConversations.participant2Handle, targetHandle) + ) + }); + + if (!senderConv) { + const [newConv] = await db.insert(chatConversations).values({ + participant1Id: user.id, + participant2Handle: targetHandle, + lastMessageAt: new Date(), + lastMessagePreview: '[Encrypted]' + }).returning(); + senderConv = newConv; + } + + await db.insert(chatMessages).values({ + conversationId: senderConv.id, + senderHandle: user.handle, + senderDisplayName: user.displayName, + senderAvatarUrl: user.avatarUrl, + senderNodeDomain: null, // It's ME, so null + senderDid: user.did, + encryptedContent: encryptedContent, + deliveredAt: new Date(), + }); + + return NextResponse.json({ success: true }); } } catch (error: any) {