From 25f71e320b1897d7395ac10d189c1e23b41b9e00 Mon Sep 17 00:00:00 2001 From: Christopher Date: Tue, 27 Jan 2026 14:09:25 -0800 Subject: [PATCH] Implement federated chat conversation deletion Adds logic to notify remote nodes when a chat conversation is deleted, and introduces a new API endpoint to receive and process deletion requests from other swarm nodes. Ensures that only authorized participants can trigger deletions and handles both local and remote user scenarios. --- .../swarm/chat/conversations/[id]/route.ts | 53 +++++++++++- src/app/api/swarm/chat/delete/route.ts | 81 +++++++++++++++++++ 2 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 src/app/api/swarm/chat/delete/route.ts diff --git a/src/app/api/swarm/chat/conversations/[id]/route.ts b/src/app/api/swarm/chat/conversations/[id]/route.ts index a0841ab..5b233ea 100644 --- a/src/app/api/swarm/chat/conversations/[id]/route.ts +++ b/src/app/api/swarm/chat/conversations/[id]/route.ts @@ -43,8 +43,57 @@ export async function DELETE( // Delete the entire conversation and all messages (cascade will handle messages) await db.delete(chatConversations).where(eq(chatConversations.id, id)); - // TODO: Send a federation message to the other party to delete their copy - // This would require implementing a swarm protocol for conversation deletion + // Send deletion request to the other party + const participant2Handle = conversation.participant2Handle; + const isRemote = participant2Handle.includes('@'); + + if (isRemote) { + // Extract domain from handle (format: handle@domain) + const domain = participant2Handle.split('@')[1]; + const handle = participant2Handle.split('@')[0]; + + try { + const protocol = domain.includes('localhost') ? 'http' : 'https'; + const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost'; + + await fetch(`${protocol}://${domain}/api/swarm/chat/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + senderHandle: session.user.handle, + senderNodeDomain: nodeDomain, + recipientHandle: handle, + conversationId: id, + timestamp: new Date().toISOString(), + }), + }); + + console.log(`[Chat Delete] Sent deletion request to ${domain}`); + } catch (error) { + console.error('[Chat Delete] Failed to notify remote node:', error); + // Continue anyway - local deletion succeeded + } + } else { + // Local user - find and delete their conversation too + const recipientUser = await db.query.users.findFirst({ + where: eq(db.users.handle, participant2Handle), + }); + + if (recipientUser) { + // Find their conversation with us + const recipientConversation = await db.query.chatConversations.findFirst({ + where: and( + eq(chatConversations.participant1Id, recipientUser.id), + eq(chatConversations.participant2Handle, session.user.handle) + ), + }); + + if (recipientConversation) { + await db.delete(chatConversations).where(eq(chatConversations.id, recipientConversation.id)); + console.log(`[Chat Delete] Deleted conversation for local user ${participant2Handle}`); + } + } + } return NextResponse.json({ success: true, diff --git a/src/app/api/swarm/chat/delete/route.ts b/src/app/api/swarm/chat/delete/route.ts new file mode 100644 index 0000000..444cb2e --- /dev/null +++ b/src/app/api/swarm/chat/delete/route.ts @@ -0,0 +1,81 @@ +/** + * Swarm Chat Deletion Inbox + * + * POST: Receives conversation deletion requests from other swarm nodes + * + * Security: Only allows deletion if the sender is actually a participant in the conversation + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { db, users, chatConversations } from '@/db'; +import { eq, and } from 'drizzle-orm'; +import { z } from 'zod'; + +const deletionSchema = z.object({ + senderHandle: z.string(), + senderNodeDomain: z.string(), + recipientHandle: z.string(), + conversationId: z.string().optional(), + timestamp: z.string(), +}); + +export async function POST(request: NextRequest) { + try { + if (!db) { + return NextResponse.json({ error: 'Database not available' }, { status: 503 }); + } + + const body = await request.json(); + const data = deletionSchema.parse(body); + + // 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 }); + } + + // Find the conversation with the sender + const senderFullHandle = `${data.senderHandle}@${data.senderNodeDomain}`; + const conversation = await db.query.chatConversations.findFirst({ + where: and( + eq(chatConversations.participant1Id, recipient.id), + eq(chatConversations.participant2Handle, senderFullHandle) + ), + }); + + if (!conversation) { + // Conversation doesn't exist - could be already deleted or never existed + // Return success to avoid leaking information about conversation existence + return NextResponse.json({ + success: true, + message: 'Conversation not found', + }); + } + + // SECURITY CHECK: Verify the sender is actually a participant in this conversation + // The conversation must be between the recipient (participant1) and the sender (participant2) + if (conversation.participant2Handle !== senderFullHandle) { + console.warn(`[Swarm Chat Delete] Unauthorized deletion attempt from ${senderFullHandle} for conversation with ${conversation.participant2Handle}`); + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }); + } + + // Delete the conversation (cascade will delete messages) + await db.delete(chatConversations).where(eq(chatConversations.id, conversation.id)); + + console.log(`[Swarm Chat Delete] Deleted conversation between ${recipient.handle} and ${senderFullHandle}`); + + return NextResponse.json({ + success: true, + message: 'Conversation deleted', + }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid payload', details: error.issues }, { status: 400 }); + } + console.error('Swarm chat deletion error:', error); + return NextResponse.json({ error: 'Failed to process deletion' }, { status: 500 }); + } +}