Files
Synapsis/src/app/api/swarm/chat/inbox/route.ts
T
Christopher c8e4dedd61 Add sender_did to chat messages and new chat tables
Introduces a migration to add a sender_did column to the chat_messages table and creates new tables for chat_device_bundles, chat_inbox, and chat_one_time_keys. Updates related API routes and adds a script for sender DID migration. These changes support device-based messaging and improved chat security.
2026-01-27 21:57:33 -08:00

213 lines
7.3 KiB
TypeScript

/**
* Swarm Chat Inbox
*
* POST: Receives chat messages from other swarm nodes
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, users, chatConversations, chatMessages } from '@/db';
import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
import type { SwarmChatMessagePayload } from '@/lib/swarm/chat-types';
const chatMessageSchema = z.object({
messageId: z.string(),
senderHandle: z.string(),
senderDisplayName: z.string().optional(),
senderAvatarUrl: z.string().optional(),
senderNodeDomain: z.string(),
recipientHandle: z.string(),
encryptedContent: z.string(),
timestamp: z.string(),
signature: z.string().optional(),
});
export async function POST(request: NextRequest) {
try {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const body = await request.json();
console.log('[Swarm Inbox] Received body keys:', Object.keys(body), 'action:', body.action);
// Check if this is a V2 encrypted envelope (has 'action' and 'data' fields)
// MUST check BEFORE Zod validation to avoid validation errors
if (body.action === 'chat.deliver' && body.data) {
// V2 E2EE Message - store in chatInbox
const { recipientDid, recipientDeviceId, ciphertext } = body.data;
if (!recipientDid || !ciphertext) {
return NextResponse.json({ error: 'Invalid V2 payload' }, { status: 400 });
}
// Find recipient by DID
const recipient = await db.query.users.findFirst({
where: eq(users.did, recipientDid)
});
if (!recipient) {
return NextResponse.json({ error: 'Recipient not found' }, { status: 404 });
}
// Import schemas
const { chatInbox, chatConversations, chatMessages } = await import('@/db/schema');
// Store in V2 inbox
await db.insert(chatInbox).values({
senderDid: body.did, // From signed envelope
recipientDid,
recipientDeviceId: recipientDeviceId || null,
envelope: JSON.stringify(body),
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
});
// Also create conversation and message for recipient UI
// Extract sender info from envelope
const senderHandle = body.handle || body.did; // Fallback to DID if no handle
const senderFullHandle = `${senderHandle}@${body.did?.split(':')[2] || 'unknown'}`; // Extract domain from DID
// Get or create conversation for recipient
let conversation = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, recipient.id),
eq(chatConversations.participant2Handle, senderFullHandle)
)
});
if (!conversation) {
const [newConv] = await db.insert(chatConversations).values({
participant1Id: recipient.id,
participant2Handle: senderFullHandle,
lastMessageAt: new Date(),
lastMessagePreview: '[Encrypted message]'
}).returning();
conversation = newConv;
console.log(`[Swarm Chat V2] Created conversation for recipient:`, conversation.id);
} else {
// Update last message time
await db.update(chatConversations)
.set({
lastMessageAt: new Date(),
lastMessagePreview: '[Encrypted message]'
})
.where(eq(chatConversations.id, conversation.id));
}
// Store message reference so it appears in UI
// Store full envelope data as JSON so we can decrypt later
const envelopeData = {
did: body.did,
handle: body.handle,
ciphertext: body.data.ciphertext
};
const messageId = crypto.randomUUID();
await db.insert(chatMessages).values({
conversationId: conversation.id,
senderHandle: senderFullHandle,
senderDisplayName: null, // Unknown until decrypted
senderAvatarUrl: null,
senderNodeDomain: body.did?.split(':')[2] || null,
encryptedContent: JSON.stringify(envelopeData), // Full envelope for decryption
senderChatPublicKey: null,
swarmMessageId: `swarm:v2:${messageId}`,
deliveredAt: new Date(),
readAt: null,
});
console.log(`[Swarm Chat V2] Received encrypted message for ${recipientDid}, conversation:`, conversation.id);
return NextResponse.json({
success: true,
message: 'V2 message received',
version: 2
});
}
// V1 Legacy Message Format - validate with Zod
const data = chatMessageSchema.parse(body) as SwarmChatMessagePayload;
// Find the recipient (local user)
const recipient = await db.query.users.findFirst({
where: eq(users.handle, data.recipientHandle.toLowerCase()),
});
if (!recipient) {
return NextResponse.json({ error: 'Recipient not found' }, { status: 404 });
}
if (recipient.isSuspended) {
return NextResponse.json({ error: 'Recipient not available' }, { status: 404 });
}
// Check if message already exists (prevent duplicates)
const swarmMessageId = `swarm:${data.senderNodeDomain}:${data.messageId}`;
const existingMessage = await db.query.chatMessages.findFirst({
where: eq(chatMessages.swarmMessageId, swarmMessageId),
});
if (existingMessage) {
return NextResponse.json({
success: true,
message: 'Message already received',
});
}
// Get or create conversation
const senderFullHandle = `${data.senderHandle}@${data.senderNodeDomain}`;
let conversation = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, recipient.id),
eq(chatConversations.participant2Handle, senderFullHandle)
),
});
if (!conversation) {
const [newConversation] = await db.insert(chatConversations).values({
participant1Id: recipient.id,
participant2Handle: senderFullHandle,
lastMessageAt: new Date(data.timestamp),
lastMessagePreview: '[Encrypted message]',
}).returning();
conversation = newConversation;
}
// Store the message
const [newMessage] = await db.insert(chatMessages).values({
conversationId: conversation.id,
senderHandle: senderFullHandle,
senderDisplayName: data.senderDisplayName,
senderAvatarUrl: data.senderAvatarUrl,
senderNodeDomain: data.senderNodeDomain,
encryptedContent: data.encryptedContent,
swarmMessageId,
deliveredAt: new Date(),
createdAt: new Date(data.timestamp),
}).returning();
// Update conversation last message
await db.update(chatConversations)
.set({
lastMessageAt: new Date(data.timestamp),
lastMessagePreview: '[Encrypted message]',
updatedAt: new Date(),
})
.where(eq(chatConversations.id, conversation.id));
console.log(`[Swarm Chat] Received message from ${senderFullHandle} to ${data.recipientHandle}`);
return NextResponse.json({
success: true,
message: 'Message received',
messageId: newMessage.id,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid payload', details: error.issues }, { status: 400 });
}
console.error('Swarm chat inbox error:', error);
return NextResponse.json({ error: 'Failed to receive message' }, { status: 500 });
}
}