Refactor chat system to remove E2EE and simplify messaging

Removed all E2EE chat endpoints, crypto logic, and related API routes, transitioning chat to plain text storage and transport. Updated chat send/receive endpoints to use signed actions and enforce DM privacy settings. Cleaned up Swarm chat inbox, key management, and related debug endpoints. Updated user profile to support DM privacy, improved search for handle queries, and refactored conversation/message logic to support the new model. Migrated bot settings and privacy settings to new locations.
This commit is contained in:
Christopher
2026-01-28 13:05:34 -08:00
parent b52b3f9804
commit ceae76d58f
44 changed files with 1945 additions and 3058 deletions
+6
View File
@@ -10,6 +10,7 @@ const updateProfileSchema = z.object({
avatarUrl: z.string().url().or(z.string().length(0)).optional().nullable(), avatarUrl: z.string().url().or(z.string().length(0)).optional().nullable(),
headerUrl: z.string().url().or(z.string().length(0)).optional().nullable(), headerUrl: z.string().url().or(z.string().length(0)).optional().nullable(),
website: z.string().url().or(z.string().length(0)).optional().nullable(), website: z.string().url().or(z.string().length(0)).optional().nullable(),
dmPrivacy: z.enum(['everyone', 'following', 'none']).optional(),
}); });
export async function GET() { export async function GET() {
@@ -33,6 +34,7 @@ export async function GET() {
avatarUrl: session.user.avatarUrl, avatarUrl: session.user.avatarUrl,
bio: session.user.bio, bio: session.user.bio,
website: session.user.website, website: session.user.website,
dmPrivacy: session.user.dmPrivacy,
did: session.user.did, did: session.user.did,
publicKey: session.user.publicKey, publicKey: session.user.publicKey,
privateKeyEncrypted: session.user.privateKeyEncrypted, privateKeyEncrypted: session.user.privateKeyEncrypted,
@@ -60,6 +62,7 @@ export async function PATCH(request: Request) {
avatarUrl?: string | null; avatarUrl?: string | null;
headerUrl?: string | null; headerUrl?: string | null;
website?: string | null; website?: string | null;
dmPrivacy?: 'everyone' | 'following' | 'none';
updatedAt?: Date; updatedAt?: Date;
} = {}; } = {};
@@ -68,6 +71,7 @@ export async function PATCH(request: Request) {
if (data.avatarUrl !== undefined) updateData.avatarUrl = data.avatarUrl === '' ? null : data.avatarUrl; if (data.avatarUrl !== undefined) updateData.avatarUrl = data.avatarUrl === '' ? null : data.avatarUrl;
if (data.headerUrl !== undefined) updateData.headerUrl = data.headerUrl === '' ? null : data.headerUrl; if (data.headerUrl !== undefined) updateData.headerUrl = data.headerUrl === '' ? null : data.headerUrl;
if (data.website !== undefined) updateData.website = data.website === '' ? null : data.website; if (data.website !== undefined) updateData.website = data.website === '' ? null : data.website;
if (data.dmPrivacy !== undefined) updateData.dmPrivacy = data.dmPrivacy;
if (Object.keys(updateData).length === 0) { if (Object.keys(updateData).length === 0) {
return NextResponse.json({ return NextResponse.json({
@@ -79,6 +83,7 @@ export async function PATCH(request: Request) {
bio: currentUser.bio, bio: currentUser.bio,
headerUrl: currentUser.headerUrl, headerUrl: currentUser.headerUrl,
website: currentUser.website, website: currentUser.website,
dmPrivacy: currentUser.dmPrivacy,
followersCount: currentUser.followersCount, followersCount: currentUser.followersCount,
followingCount: currentUser.followingCount, followingCount: currentUser.followingCount,
postsCount: currentUser.postsCount, postsCount: currentUser.postsCount,
@@ -103,6 +108,7 @@ export async function PATCH(request: Request) {
bio: updatedUser.bio, bio: updatedUser.bio,
headerUrl: updatedUser.headerUrl, headerUrl: updatedUser.headerUrl,
website: updatedUser.website, website: updatedUser.website,
dmPrivacy: updatedUser.dmPrivacy,
followersCount: updatedUser.followersCount, followersCount: updatedUser.followersCount,
followingCount: updatedUser.followingCount, followingCount: updatedUser.followingCount,
postsCount: updatedUser.postsCount, postsCount: updatedUser.postsCount,
-60
View File
@@ -1,60 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { chatInbox } from '@/db/schema';
import { eq, and, or, isNull } from 'drizzle-orm';
import { getSession } from '@/lib/auth';
/**
* GET /api/chat/inbox
* Poll for new encrypted envelopes for this device.
*/
export async function GET(request: NextRequest) {
try {
const session = await getSession();
if (!session?.user?.did) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { did } = session.user;
const { searchParams } = new URL(request.url);
const deviceId = searchParams.get('deviceId');
if (!deviceId) {
return NextResponse.json({ error: 'Device ID required' }, { status: 400 });
}
// Fetch messages for this user AND (this device OR all devices)
const messages = await db.select().from(chatInbox).where(
and(
eq(chatInbox.recipientDid, did),
or(
eq(chatInbox.recipientDeviceId, deviceId),
isNull(chatInbox.recipientDeviceId) // Broadcasts (if any)
),
eq(chatInbox.isRead, false)
)
);
// TODO: Mark them as read immediately?
// Or client must ACK?
// For now, we return them. Client deals with idempotency.
// If we mark read, checking on another tab might miss them if race condition.
// But uniqueness is (id).
return NextResponse.json({ messages });
} catch (error: any) {
console.error('Inbox poll fail:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
/**
* DELETE /api/chat/inbox
* Acknowledge/Delete processed messages
*/
export async function DELETE(request: NextRequest) {
// ... Implement ACK cleaning
return NextResponse.json({ success: true });
}
-70
View File
@@ -1,70 +0,0 @@
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 });
}
const handle = searchParams.get('handle');
// Helper to fetch and check with timeout
const tryFetch = async (url: string) => {
console.log(`[Proxy] Fetching keys from: ${url}`);
try {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), 5000); // 5s timeout
const res = await fetch(url, {
headers: { 'Accept': 'application/json' },
signal: controller.signal
});
clearTimeout(id);
if (res.ok) return await res.json();
const text = await res.text();
console.warn(`[Proxy] Fetch failed for ${url} (${res.status}): ${text}`);
return null;
} catch (err: any) {
console.warn(`[Proxy] Fetch error for ${url}: ${err.name} - ${err.message}`);
return null;
}
};
// 1. Try Primary DID
let primaryUrl = '';
// 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';
primaryUrl = `${protocol}://${domain}/.well-known/synapsis/chat/${did}`;
}
} else if (nodeDomain) {
const protocol = nodeDomain.includes('localhost') ? 'http' : 'https';
primaryUrl = `${protocol}://${nodeDomain}/.well-known/synapsis/chat/${did}`;
}
if (primaryUrl) {
const data = await tryFetch(primaryUrl);
if (data) return NextResponse.json(data);
}
// 2. Try Fallback: did:web (if handle and domain provided)
// The remote user might be indexed by did:web even if we know them as did:synapsis
if (nodeDomain && handle) {
const didWeb = `did:web:${nodeDomain}:${handle}`;
const protocol = nodeDomain.includes('localhost') ? 'http' : 'https';
const fallbackUrl = `${protocol}://${nodeDomain}/.well-known/synapsis/chat/${didWeb}`;
console.log(`[Proxy] Primary failed. Trying fallback: ${didWeb}`);
const data = await tryFetch(fallbackUrl);
if (data) return NextResponse.json(data);
}
return NextResponse.json({ error: 'Remote keys not found (checked primary and fallback)' }, { status: 404 });
}
-233
View File
@@ -1,233 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { chatDeviceBundles, handleRegistry, remoteIdentityCache, users } from '@/db/schema';
import { requireAuth } from '@/lib/auth';
import { eq, and, gt } from 'drizzle-orm';
/**
* GET /api/chat/keys?did=<did>
* Fetch public key for a user
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const did = searchParams.get('did');
if (!did) {
return NextResponse.json({ error: 'Missing did parameter' }, { status: 400 });
}
console.log('[Chat Keys GET] Looking for DID:', did);
const bundle = await db.query.chatDeviceBundles.findFirst({
where: eq(chatDeviceBundles.did, did),
orderBy: (bundles, { desc }) => [desc(bundles.lastSeenAt)],
});
console.log('[Chat Keys GET] Found local bundle:', bundle ? 'YES' : 'NO');
if (bundle) {
console.log('[Chat Keys GET] Bundle data:', {
userId: bundle.userId,
did: bundle.did,
deviceId: bundle.deviceId,
hasIdentityKey: !!bundle.identityKey,
});
// For libsodium, we just need the public key
return NextResponse.json({
publicKey: bundle.identityKey,
});
}
// If not found locally, check remote identity cache
const cached = await db.query.remoteIdentityCache.findFirst({
where: and(
eq(remoteIdentityCache.did, did),
gt(remoteIdentityCache.expiresAt, new Date())
),
});
// DEBUG: Force refresh for now to fix development key mismatches
// if (cached) {
// console.log('[Chat Keys GET] Found cached remote key');
// return NextResponse.json({
// publicKey: cached.publicKey,
// });
// }
// If not in cache, try to resolve DID to a node
console.log('[Chat Keys GET] resolving DID to node...');
const handleEntry = await db.query.handleRegistry.findFirst({
where: eq(handleRegistry.did, did),
});
if (handleEntry) {
const { nodeDomain } = handleEntry;
console.log('[Chat Keys GET] Resolved to node:', nodeDomain);
// Fetch from remote node
const remoteUrl = `https://${nodeDomain}/api/chat/keys?did=${encodeURIComponent(did)}`;
console.log('[Chat Keys GET] Fetching from remote:', remoteUrl);
try {
const remoteRes = await fetch(remoteUrl);
if (remoteRes.ok) {
const data = await remoteRes.json();
if (data.publicKey) {
console.log('[Chat Keys GET] Successfully fetched remote key');
// Cache it
await db.insert(remoteIdentityCache).values({
did: did,
publicKey: data.publicKey,
fetchedAt: new Date(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
}).onConflictDoUpdate({
target: remoteIdentityCache.did,
set: {
publicKey: data.publicKey,
fetchedAt: new Date(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
}
});
return NextResponse.json({
publicKey: data.publicKey,
});
}
} else {
console.warn('[Chat Keys GET] Remote fetch failed:', remoteRes.status, remoteRes.statusText);
}
} catch (err) {
console.error('[Chat Keys GET] Remote fetch error:', err);
}
// FALLBACK: Try fetching from Swarm User Profile (standard endpoint)
if (!cached) {
console.log('[Chat Keys GET] Trying fallback to Swarm Profile...');
const swarmUrl = `https://${nodeDomain}/api/swarm/users/${handleEntry.handle}`;
console.log('[Chat Keys GET] Fetching Swarm Profile:', swarmUrl);
try {
const swarmRes = await fetch(swarmUrl);
if (swarmRes.ok) {
const data = await swarmRes.json();
const chatKey = data.profile?.chatPublicKey;
if (chatKey) {
console.log('[Chat Keys GET] Found key in Swarm Profile');
// Cache it
await db.insert(remoteIdentityCache).values({
did: did,
publicKey: chatKey,
fetchedAt: new Date(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
}).onConflictDoUpdate({
target: remoteIdentityCache.did,
set: {
publicKey: chatKey,
fetchedAt: new Date(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
}
});
return NextResponse.json({
publicKey: chatKey,
});
} else {
console.warn('[Chat Keys GET] Swarm Profile found but no chatPublicKey');
}
} else {
console.warn('[Chat Keys GET] Swarm Profile fetch failed:', swarmRes.status);
}
} catch (err) {
console.error('[Chat Keys GET] Swarm Profile fetch error:', err);
}
}
} else {
console.log('[Chat Keys GET] DID not found in handle registry');
}
return NextResponse.json({ error: 'No keys found' }, { status: 404 });
} catch (error: any) {
console.error('[Chat Keys] Failed to fetch keys:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
/**
* POST /api/chat/keys
* Publish public key
*
* Body: {
* publicKey: string (base64)
* }
*/
export async function POST(request: NextRequest) {
try {
const user = await requireAuth();
console.log('[Chat Keys POST] User:', user.handle, 'DID:', user.did, 'UserID:', user.id);
const body = await request.json();
const { publicKey } = body;
console.log('[Chat Keys POST] Received publicKey:', publicKey ? publicKey.substring(0, 20) + '...' : 'MISSING');
if (!publicKey) {
return NextResponse.json({ error: 'Missing publicKey' }, { status: 400 });
}
// Check if bundle exists
const existing = await db.query.chatDeviceBundles.findFirst({
where: and(
eq(chatDeviceBundles.userId, user.id),
eq(chatDeviceBundles.deviceId, '1')
)
});
console.log('[Chat Keys POST] Existing bundle found:', existing ? 'YES' : 'NO');
if (existing) {
// Update existing bundle
console.log('[Chat Keys POST] Updating existing bundle...');
await db.update(chatDeviceBundles)
.set({
identityKey: publicKey,
did: user.did, // Update DID too in case it changed
lastSeenAt: new Date(),
})
.where(
and(
eq(chatDeviceBundles.userId, user.id),
eq(chatDeviceBundles.deviceId, '1')
)
);
console.log('[Chat Keys POST] Updated existing key');
} else {
console.log('[Chat Keys POST] Inserted new key');
await db.insert(chatDeviceBundles).values({
userId: user.id,
did: user.did,
deviceId: '1',
identityKey: publicKey,
signedPreKey: 'libsodium', // Placeholder
registrationId: 1,
signature: 'libsodium',
});
console.log('[Chat Keys POST] Inserted new key');
}
// Sync to users table too for profile discovery
await db.update(users)
.set({ chatPublicKey: publicKey })
.where(eq(users.id, user.id));
console.log('[Chat Keys POST] Key published successfully for', user.handle);
return NextResponse.json({ success: true });
} catch (error: any) {
console.error('[Chat Keys] Failed to publish key:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+103 -56
View File
@@ -3,108 +3,155 @@ import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db'; import { db } from '@/db';
import { chatConversations, chatMessages, users, handleRegistry } from '@/db/schema'; import { chatConversations, chatMessages, users, handleRegistry } from '@/db/schema';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { verifyActionSignature, type SignedAction } from '@/lib/auth/verify-signature';
/** /**
* POST /api/chat/receive * POST /api/chat/receive
* Endpoint for receiving federated chat messages from other nodes. * Endpoint for receiving federated chat messages from other nodes.
* * Expects a SignedAction payload from the sender.
* 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) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const signedAction: SignedAction = await request.json();
const { const { did, handle, data } = signedAction;
senderDid, const { recipientDid, content } = data || {};
senderHandle,
senderDisplayName,
senderAvatarUrl,
senderNodeDomain,
recipientDid,
encryptedContent
} = body;
// Basic validation if (!did || !handle || !recipientDid || !content) {
if (!senderDid || !senderHandle || !recipientDid || !encryptedContent || !senderNodeDomain) { return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
} }
console.log(`[Chat Receive] From: ${senderHandle} (${senderNodeDomain}), To: ${recipientDid} [${encryptedContent.substring(0, 20)}...]`); console.log(`[Chat Receive] From: ${handle} (DID: ${did}), To: ${recipientDid}`);
// Ensure remote handle is fully qualified // 1. Resolve Sender Public Key
let finalSenderHandle = senderHandle; let senderUser = await db.query.users.findFirst({
if (!finalSenderHandle.includes('@') && senderNodeDomain) { where: eq(users.did, did)
finalSenderHandle = `${senderHandle}@${senderNodeDomain}`; });
let publicKey = senderUser?.publicKey;
let senderDisplayName = senderUser?.displayName || handle;
let senderAvatarUrl = senderUser?.avatarUrl;
let senderNodeDomain: string | null = null;
if (!senderUser) {
// Unknown user - likely remote. We need to fetch their profile to get the public key.
// Derive domain from handle if possible
if (handle.includes('@')) {
const parts = handle.split('@');
senderNodeDomain = parts[parts.length - 1];
} else {
// Try handle registry (though we likely don't have it if we don't have the user)
const registryEntry = await db.query.handleRegistry.findFirst({
where: eq(handleRegistry.did, did)
});
if (registryEntry) senderNodeDomain = registryEntry.nodeDomain;
} }
// 1. Find local recipient if (senderNodeDomain) {
try {
const protocol = senderNodeDomain.includes('localhost') ? 'http' : 'https';
// Fetch profile from remote node
// Assuming /api/users/:handle convention
const remoteHandle = handle.includes('@') ? handle.split('@')[0] : handle;
const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`);
if (res.ok) {
const profileData = await res.json();
const remoteProfile = profileData.user;
if (remoteProfile && remoteProfile.publicKey) {
if (remoteProfile.did !== did) {
console.error('DID mismatch for remote user');
} else {
publicKey = remoteProfile.publicKey;
senderDisplayName = remoteProfile.displayName || handle;
senderAvatarUrl = remoteProfile.avatarUrl;
}
}
} else {
console.error('Failed to fetch remote profile:', res.status);
}
} catch (e) {
console.error('Remote profile fetch failed:', e);
}
}
}
if (!publicKey) {
return NextResponse.json({ error: 'Could not resolve sender public key' }, { status: 401 });
}
// 2. Verify Signature
const isValid = await verifyActionSignature(signedAction, publicKey);
if (!isValid) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 });
}
// 3. Find Local Recipient
const recipientUser = await db.query.users.findFirst({ const recipientUser = await db.query.users.findFirst({
where: eq(users.did, recipientDid) where: eq(users.did, recipientDid)
}); });
if (!recipientUser) { if (!recipientUser) {
return NextResponse.json({ error: 'Recipient not found' }, { status: 404 }); return NextResponse.json({ error: 'Recipient not found on this node' }, { status: 404 });
} }
// 2. Find or Create Conversation // 4. Find or Create Conversation
// For the RECIPIENT, the conversation is with the SENDER (Remote) // For the RECIPIENT, the conversation is with the SENDER
// Use full handle
const fullSenderHandle = handle.includes('@') ? handle : (senderNodeDomain ? `${handle}@${senderNodeDomain}` : handle);
let conversation = await db.query.chatConversations.findFirst({ let conversation = await db.query.chatConversations.findFirst({
where: and( where: and(
eq(chatConversations.participant1Id, recipientUser.id), eq(chatConversations.participant1Id, recipientUser.id),
eq(chatConversations.participant2Handle, finalSenderHandle) eq(chatConversations.participant2Handle, fullSenderHandle)
) )
}); });
if (!conversation) { if (!conversation) {
const [newConv] = await db.insert(chatConversations).values({ const [newConv] = await db.insert(chatConversations).values({
participant1Id: recipientUser.id, participant1Id: recipientUser.id,
participant2Handle: finalSenderHandle, participant2Handle: fullSenderHandle,
lastMessageAt: new Date(), lastMessageAt: new Date(),
lastMessagePreview: '[Encrypted Message]' lastMessagePreview: content.slice(0, 50)
}).returning(); }).returning();
conversation = newConv; conversation = newConv;
} } else {
// Update preview
// 3. Store Message
await db.insert(chatMessages).values({
conversationId: conversation.id,
senderHandle: finalSenderHandle,
senderDisplayName: senderDisplayName || senderHandle,
senderAvatarUrl: senderAvatarUrl,
senderNodeDomain: senderNodeDomain,
senderDid: senderDid,
encryptedContent: encryptedContent,
deliveredAt: new Date(),
});
// 4. Update conversation timestamp
await db.update(chatConversations) await db.update(chatConversations)
.set({ .set({
lastMessageAt: new Date(), lastMessageAt: new Date(),
lastMessagePreview: '[Encrypted Message]' lastMessagePreview: content.slice(0, 50),
updatedAt: new Date()
}) })
.where(eq(chatConversations.id, conversation.id)); .where(eq(chatConversations.id, conversation.id));
}
// 5. Upsert sender into HandleRegistry ensures we can reply/fetch keys later // 5. Store Message
await db.insert(chatMessages).values({
conversationId: conversation.id,
senderHandle: fullSenderHandle,
senderDisplayName: senderDisplayName,
senderAvatarUrl: senderAvatarUrl,
senderNodeDomain: senderNodeDomain,
senderDid: did,
content: content,
encryptedContent: '',
deliveredAt: new Date(),
});
// 6. Update Registry (to ensure we can reply efficiently)
if (senderNodeDomain) {
await db.insert(handleRegistry).values({ await db.insert(handleRegistry).values({
handle: finalSenderHandle, // user@domain handle: fullSenderHandle, // user@domain
did: senderDid, did: did,
nodeDomain: senderNodeDomain nodeDomain: senderNodeDomain
}).onConflictDoUpdate({ }).onConflictDoUpdate({
target: handleRegistry.handle, target: handleRegistry.handle,
set: { set: {
did: senderDid, did: did,
nodeDomain: senderNodeDomain nodeDomain: senderNodeDomain
} }
}); });
}
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
+103 -72
View File
@@ -1,31 +1,32 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db'; import { db } from '@/db';
import { chatConversations, chatMessages, users, handleRegistry } from '@/db/schema'; import { chatConversations, chatMessages, users, handleRegistry, follows } from '@/db/schema';
import { requireAuth } from '@/lib/auth'; import { requireSignedAction } from '@/lib/auth/verify-signature';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
const chatSendSchema = z.object({
recipientDid: z.string(),
recipientHandle: z.string(),
content: z.string().min(1).max(5000),
});
/** /**
* POST /api/chat/send * POST /api/chat/send
* Store encrypted message (server never decrypts) * Send a signed chat message (verified with DID)
* * Stores plain text content.
* Body: {
* recipientDid: string,
* senderPublicKey: string (base64),
* ciphertext: string (base64),
* nonce: string (base64),
* recipientHandle?: string
* }
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const user = await requireAuth(); // Parse the signed action from the request body
const signedAction = await request.json();
const body = await request.json(); // Strictly verify the signature and get the user
const { recipientDid, senderPublicKey, ciphertext, nonce, recipientHandle } = body; const user = await requireSignedAction(signedAction);
if (!recipientDid || !senderPublicKey || !ciphertext || !nonce) { // Extract message data
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); const data = chatSendSchema.parse(signedAction.data);
} const { recipientDid, recipientHandle, content } = data;
// Check if recipient is local // Check if recipient is local
const recipientUser = await db.query.users.findFirst({ const recipientUser = await db.query.users.findFirst({
@@ -33,9 +34,30 @@ export async function POST(request: NextRequest) {
}); });
if (recipientUser) { if (recipientUser) {
// Reject if recipient is a bot
if (recipientUser.isBot) {
return NextResponse.json({ error: 'Cannot DM a bot account' }, { status: 400 });
}
// Check DM privacy settings
if (recipientUser.dmPrivacy === 'none') {
return NextResponse.json({ error: 'This user does not accept direct messages' }, { status: 403 });
} else if (recipientUser.dmPrivacy === 'following') {
// Check if recipient follows the sender
const isFollowingSender = await db.query.follows.findFirst({
where: and(
eq(follows.followerId, recipientUser.id),
eq(follows.followingId, user.id)
)
});
if (!isFollowingSender) {
return NextResponse.json({ error: 'This user only accepts messages from accounts they follow' }, { status: 403 });
}
}
// LOCAL RECIPIENT // LOCAL RECIPIENT
// Ensure conversations exist // Ensure conversations exist for both sides
// 1. Recipient's Inbox (Recipient -> User)
let recipientConv = await db.query.chatConversations.findFirst({ let recipientConv = await db.query.chatConversations.findFirst({
where: and( where: and(
eq(chatConversations.participant1Id, recipientUser.id), eq(chatConversations.participant1Id, recipientUser.id),
@@ -48,11 +70,21 @@ export async function POST(request: NextRequest) {
participant1Id: recipientUser.id, participant1Id: recipientUser.id,
participant2Handle: user.handle, participant2Handle: user.handle,
lastMessageAt: new Date(), lastMessageAt: new Date(),
lastMessagePreview: '[Encrypted]' lastMessagePreview: content.slice(0, 50)
}).returning(); }).returning();
recipientConv = newConv; recipientConv = newConv;
} else {
// Update preview
await db.update(chatConversations)
.set({
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50),
updatedAt: new Date()
})
.where(eq(chatConversations.id, recipientConv.id));
} }
// 2. Sender's Sent Box (User -> Recipient)
let senderConv = await db.query.chatConversations.findFirst({ let senderConv = await db.query.chatConversations.findFirst({
where: and( where: and(
eq(chatConversations.participant1Id, user.id), eq(chatConversations.participant1Id, user.id),
@@ -65,21 +97,20 @@ export async function POST(request: NextRequest) {
participant1Id: user.id, participant1Id: user.id,
participant2Handle: recipientUser.handle, participant2Handle: recipientUser.handle,
lastMessageAt: new Date(), lastMessageAt: new Date(),
lastMessagePreview: '[Encrypted]' lastMessagePreview: content.slice(0, 50)
}).returning(); }).returning();
senderConv = newConv; senderConv = newConv;
} else {
await db.update(chatConversations)
.set({
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50),
updatedAt: new Date()
})
.where(eq(chatConversations.id, senderConv.id));
} }
// Store encrypted message (libsodium format) // Create message for recipient (Inbox)
// Include recipientDid so sender can decrypt their own sent messages
const messageData = {
senderPublicKey,
recipientDid, // Add this so sender knows who to decrypt with
ciphertext,
nonce,
};
// Create message for recipient
await db.insert(chatMessages).values({ await db.insert(chatMessages).values({
conversationId: recipientConv.id, conversationId: recipientConv.id,
senderHandle: user.handle, senderHandle: user.handle,
@@ -87,11 +118,13 @@ export async function POST(request: NextRequest) {
senderAvatarUrl: user.avatarUrl, senderAvatarUrl: user.avatarUrl,
senderNodeDomain: null, senderNodeDomain: null,
senderDid: user.did, senderDid: user.did,
encryptedContent: JSON.stringify(messageData), content: content,
// Encrypted fields are null for plain text chat
encryptedContent: '',
deliveredAt: new Date(), deliveredAt: new Date(),
}); });
// Create message for sender // Create message for sender (Sent)
await db.insert(chatMessages).values({ await db.insert(chatMessages).values({
conversationId: senderConv.id, conversationId: senderConv.id,
senderHandle: user.handle, senderHandle: user.handle,
@@ -99,61 +132,44 @@ export async function POST(request: NextRequest) {
senderAvatarUrl: user.avatarUrl, senderAvatarUrl: user.avatarUrl,
senderNodeDomain: null, senderNodeDomain: null,
senderDid: user.did, senderDid: user.did,
encryptedContent: JSON.stringify(messageData), content: content,
encryptedContent: '',
deliveredAt: new Date(), deliveredAt: new Date(),
readAt: new Date() // Sender has read their own message
}); });
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} else { } else {
// REMOTE RECIPIENT // REMOTE RECIPIENT
const { handleRegistry } = await import('@/db/schema'); // dynamic import or add to top
// 1. Resolve recipient node // 1. Resolve recipient node
const registryEntry = await db.query.handleRegistry.findFirst({ const registryEntry = await db.query.handleRegistry.findFirst({
where: eq(handleRegistry.did, recipientDid) where: eq(handleRegistry.did, recipientDid)
}); });
if (!registryEntry) { // If not in registry, try to parse from handle if it has domain
console.error('Recipient DID not found in registry:', recipientDid); let targetDomain: string | null = registryEntry?.nodeDomain || null;
return NextResponse.json({ error: 'Recipient not found in registry' }, { status: 404 }); let targetHandle = recipientHandle;
if (!targetDomain && recipientHandle.includes('@')) {
const parts = recipientHandle.split('@');
targetDomain = parts[parts.length - 1];
} }
const targetDomain = registryEntry.nodeDomain; if (!targetDomain) {
// Ensure handle is fully qualified for remote users console.error('Recipient node domain not found for:', recipientHandle);
let targetHandle = registryEntry.handle; return NextResponse.json({ error: 'Recipient node not found' }, { status: 404 });
if (!targetHandle.includes('@') && targetDomain) {
targetHandle = `${targetHandle}@${targetDomain}`;
} }
console.log(`[Remote Send] Sending to ${targetHandle} at ${targetDomain}`); console.log(`[Remote Send] Sending to ${targetHandle} at ${targetDomain}`);
// 2. Prepare Payload // 2. Send to Remote Node (Forward the Signed Action)
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 { try {
const protocol = targetDomain.includes('localhost') ? 'http' : 'https'; const protocol = targetDomain.includes('localhost') ? 'http' : 'https';
const res = await fetch(`${protocol}://${targetDomain}/api/chat/receive`, { const res = await fetch(`${protocol}://${targetDomain}/api/chat/receive`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(signedAction) // Forward the user's signed intent
}); });
if (!res.ok) { if (!res.ok) {
@@ -166,23 +182,31 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Failed to contact remote node' }, { status: 504 }); return NextResponse.json({ error: 'Failed to contact remote node' }, { status: 504 });
} }
// 4. Store "Sent" copy locally // 3. Store "Sent" copy locally
// Ensure conversation exists locally // Ensure conversation exists locally
let senderConv = await db.query.chatConversations.findFirst({ let senderConv = await db.query.chatConversations.findFirst({
where: and( where: and(
eq(chatConversations.participant1Id, user.id), eq(chatConversations.participant1Id, user.id),
eq(chatConversations.participant2Handle, targetHandle) eq(chatConversations.participant2Handle, recipientHandle)
) )
}); });
if (!senderConv) { if (!senderConv) {
const [newConv] = await db.insert(chatConversations).values({ const [newConv] = await db.insert(chatConversations).values({
participant1Id: user.id, participant1Id: user.id,
participant2Handle: targetHandle, participant2Handle: recipientHandle,
lastMessageAt: new Date(), lastMessageAt: new Date(),
lastMessagePreview: '[Encrypted]' lastMessagePreview: content.slice(0, 50)
}).returning(); }).returning();
senderConv = newConv; senderConv = newConv;
} else {
await db.update(chatConversations)
.set({
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50),
updatedAt: new Date()
})
.where(eq(chatConversations.id, senderConv.id));
} }
await db.insert(chatMessages).values({ await db.insert(chatMessages).values({
@@ -190,17 +214,24 @@ export async function POST(request: NextRequest) {
senderHandle: user.handle, senderHandle: user.handle,
senderDisplayName: user.displayName, senderDisplayName: user.displayName,
senderAvatarUrl: user.avatarUrl, senderAvatarUrl: user.avatarUrl,
senderNodeDomain: null, // It's ME, so null senderNodeDomain: null,
senderDid: user.did, senderDid: user.did,
encryptedContent: encryptedContent, content: content,
encryptedContent: '',
deliveredAt: new Date(), deliveredAt: new Date(),
readAt: new Date()
}); });
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }
} catch (error: any) { } catch (error: any) {
console.error('Send failed:', error); console.error('Send chat failed:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
}
return NextResponse.json({ error: error.message || 'Failed to send message' }, { status: 500 });
} }
} }
@@ -1,50 +0,0 @@
import { NextResponse } from 'next/server';
import { db, users, chatDeviceBundles } from '@/db';
import { eq } from 'drizzle-orm';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const handle = searchParams.get('handle');
if (!handle) {
return NextResponse.json({ error: 'Missing handle parameter' }, { status: 400 });
}
// Find user
const user = await db.query.users.findFirst({
where: eq(users.handle, handle.toLowerCase()),
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Check for device bundles
const bundles = await db.query.chatDeviceBundles.findMany({
where: eq(chatDeviceBundles.userId, user.id),
with: {
oneTimeKeys: true,
},
});
return NextResponse.json({
user: {
id: user.id,
handle: user.handle,
did: user.did,
hasChatPublicKey: !!user.chatPublicKey,
},
bundles: bundles.map(b => ({
deviceId: b.deviceId,
identityKey: b.identityKey?.substring(0, 20) + '...',
hasSignedPreKey: !!b.signedPreKey,
oneTimeKeysCount: b.oneTimeKeys.length,
})),
bundleCount: bundles.length,
});
} catch (error) {
console.error('Check chat keys error:', error);
return NextResponse.json({ error: 'Failed to check keys' }, { status: 500 });
}
}
-19
View File
@@ -1,19 +0,0 @@
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { chatDeviceBundles } from '@/db/schema';
export async function GET() {
try {
const bundles = await db.select({
did: chatDeviceBundles.did,
userId: chatDeviceBundles.userId,
deviceId: chatDeviceBundles.deviceId,
identityKey: chatDeviceBundles.identityKey,
createdAt: chatDeviceBundles.createdAt,
}).from(chatDeviceBundles).orderBy(chatDeviceBundles.createdAt);
return NextResponse.json({ bundles, count: bundles.length });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
@@ -1,18 +0,0 @@
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { chatDeviceBundles } from '@/db/schema';
export async function POST() {
try {
console.log('[Debug] Clearing all chat device bundles...');
await db.delete(chatDeviceBundles);
console.log('[Debug] Cleared all chat device bundles');
return NextResponse.json({ success: true, message: 'Cleared all chat keys' });
} catch (error: any) {
console.error('[Debug] Failed to clear keys:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+26
View File
@@ -65,12 +65,37 @@ export async function GET(request: Request) {
} }
} }
const isHandleSearch = query.trim().startsWith('@');
const searchPattern = `%${localSearchQuery}%`; const searchPattern = `%${localSearchQuery}%`;
let searchUsers: SearchUser[] = []; let searchUsers: SearchUser[] = [];
let searchPosts: typeof posts.$inferSelect[] = []; let searchPosts: typeof posts.$inferSelect[] = [];
// Search users // Search users
if (type === 'all' || type === 'users') { if (type === 'all' || type === 'users') {
if (isHandleSearch) {
// Try exact match first
const exactMatch = await db.select({
id: users.id,
handle: users.handle,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
bio: users.bio,
isBot: users.isBot,
})
.from(users)
.where(and(
eq(users.handle, localSearchQuery),
eq(users.isSuspended, false),
eq(users.isSilenced, false)
))
.limit(1);
if (exactMatch.length > 0) {
searchUsers = exactMatch;
}
}
if (searchUsers.length === 0) {
const userConditions = and( const userConditions = and(
or( or(
ilike(users.handle, searchPattern), ilike(users.handle, searchPattern),
@@ -95,6 +120,7 @@ export async function GET(request: Request) {
// Filter out remote placeholder users (those with @ in handle) // Filter out remote placeholder users (those with @ in handle)
searchUsers = localUsers.filter(u => !u.handle.includes('@')); searchUsers = localUsers.filter(u => !u.handle.includes('@'));
} }
}
// Swarm user lookup (exact handle@domain queries) // Swarm user lookup (exact handle@domain queries)
if ((type === 'all' || type === 'users') && searchUsers.length < limit) { if ((type === 'all' || type === 'users') && searchUsers.length < limit) {
@@ -54,7 +54,6 @@ export async function GET(request: NextRequest) {
handle: participant2Handle, handle: participant2Handle,
displayName: participant2Handle, displayName: participant2Handle,
avatarUrl: null as string | null, avatarUrl: null as string | null,
chatPublicKey: null as string | null,
}; };
// Try to get cached user info // Try to get cached user info
@@ -67,20 +66,22 @@ export async function GET(request: NextRequest) {
handle: cachedUser.handle, handle: cachedUser.handle,
displayName: cachedUser.displayName || cachedUser.handle, displayName: cachedUser.displayName || cachedUser.handle,
avatarUrl: cachedUser.avatarUrl, avatarUrl: cachedUser.avatarUrl,
chatPublicKey: cachedUser.chatPublicKey, // ECDH key for E2E chat
}; };
} }
return { return {
...conv, ...conv,
participant2: participant2Info, participant2: {
...participant2Info,
isBot: cachedUser?.isBot || false,
},
unreadCount: Number(unreadCount[0]?.count || 0), unreadCount: Number(unreadCount[0]?.count || 0),
}; };
}) })
); );
return NextResponse.json({ return NextResponse.json({
conversations: conversationsWithUnread, conversations: conversationsWithUnread.filter(c => !c.participant2.isBot),
}); });
} catch (error) { } catch (error) {
console.error('List conversations error:', error); console.error('List conversations error:', error);
-217
View File
@@ -1,217 +0,0 @@
/**
* 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 || 'unknown';
const senderNodeDomain = body.data?.senderNodeDomain || 'unknown';
const senderFullHandle = senderNodeDomain !== 'unknown'
? `${senderHandle}@${senderNodeDomain}`
: senderHandle;
console.log('[Swarm Chat V2] Sender info:', { senderHandle, senderNodeDomain, senderFullHandle });
// 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: senderNodeDomain !== 'unknown' ? senderNodeDomain : 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 });
}
}
+5 -88
View File
@@ -9,7 +9,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { db, chatConversations, chatMessages, users } from '@/db'; import { db, chatConversations, chatMessages, users } from '@/db';
import { eq, desc, and, lt, isNull } from 'drizzle-orm'; import { eq, desc, and, lt, isNull } from 'drizzle-orm';
import { getSession } from '@/lib/auth'; import { getSession } from '@/lib/auth';
import { decryptMessage } from '@/lib/swarm/chat-crypto';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
@@ -43,15 +43,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 }); return NextResponse.json({ error: 'Conversation not found' }, { status: 404 });
} }
// Get user's private key for decryption
const user = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
});
if (!user?.privateKeyEncrypted) {
return NextResponse.json({ error: 'Cannot decrypt messages' }, { status: 500 });
}
// Build query with cursor-based pagination // Build query with cursor-based pagination
const baseCondition = eq(chatMessages.conversationId, conversationId); const baseCondition = eq(chatMessages.conversationId, conversationId);
const whereCondition = cursor const whereCondition = cursor
@@ -65,90 +56,16 @@ export async function GET(request: NextRequest) {
limit, limit,
}); });
// Get recipient info for sent messages const messagesMapped = messages.map((msg) => {
const recipientHandle = conversation.participant2Handle;
let recipientPublicKey: string | null = null;
console.log('[Messages API] Fetching recipient key for:', recipientHandle);
// Check if this is a remote user (has @domain)
const isRemote = recipientHandle.includes('@');
if (isRemote) {
// Remote user - fetch from their node
const [handle, domain] = recipientHandle.split('@');
try {
const protocol = domain.includes('localhost') ? 'http' : 'https';
const response = await fetch(`${protocol}://${domain}/api/users/${handle}`);
if (response.ok) {
const data = await response.json();
recipientPublicKey = data.user?.chatPublicKey || null;
console.log('[Messages API] Fetched remote recipient key:', !!recipientPublicKey);
}
} catch (error) {
console.error('[Messages API] Failed to fetch remote recipient key:', error);
}
} else {
// Local user
const recipientUser = await db.query.users.findFirst({
where: eq(users.handle, recipientHandle),
});
recipientPublicKey = recipientUser?.chatPublicKey || null;
}
console.log('[Messages API] Recipient public key found:', !!recipientPublicKey);
// Get sender DID for received messages
const senderDids = new Map<string, string>();
for (const msg of messages) {
const isSentByMe = msg.senderHandle === session.user.handle; const isSentByMe = msg.senderHandle === session.user.handle;
if (!isSentByMe && !senderDids.has(msg.senderHandle)) {
// Try to get DID for this sender
try {
const isRemote = msg.senderHandle.includes('@');
if (isRemote) {
const [handle, domain] = msg.senderHandle.split('@');
const protocol = domain.includes('localhost') ? 'http' : 'https';
const response = await fetch(`${protocol}://${domain}/api/users/${handle}`);
if (response.ok) {
const data = await response.json();
if (data.user?.did) {
senderDids.set(msg.senderHandle, data.user.did);
}
}
} else {
const senderUser = await db.query.users.findFirst({
where: eq(users.handle, msg.senderHandle),
});
if (senderUser?.did) {
senderDids.set(msg.senderHandle, senderUser.did);
}
}
} catch (e) {
console.error('[Messages API] Failed to resolve sender DID:', e);
}
}
}
const messagesWithDecryption = messages.map((msg) => {
const isSentByMe = msg.senderHandle === session.user.handle;
const senderPubKey = isSentByMe ? recipientPublicKey : msg.senderChatPublicKey;
console.log('[Messages API] Message:', msg.id, 'isSentByMe:', isSentByMe, 'senderPubKey:', !!senderPubKey, 'msgSenderChatPubKey:', !!msg.senderChatPublicKey);
return { return {
id: msg.id, id: msg.id,
senderHandle: msg.senderHandle, senderHandle: msg.senderHandle,
senderDisplayName: msg.senderDisplayName, senderDisplayName: msg.senderDisplayName,
senderAvatarUrl: msg.senderAvatarUrl, senderAvatarUrl: msg.senderAvatarUrl,
senderDid: isSentByMe ? undefined : senderDids.get(msg.senderHandle), // Add DID for received messages senderDid: msg.senderDid,
// For decryption: content: msg.content,
// - Sent messages: need recipient's public key
// - Received messages: need sender's public key
senderPublicKey: senderPubKey,
isE2E: !!msg.senderChatPublicKey || (isSentByMe && !!recipientPublicKey),
encryptedContent: msg.encryptedContent, // This is now the full envelope JSON
deliveredAt: msg.deliveredAt, deliveredAt: msg.deliveredAt,
readAt: msg.readAt, readAt: msg.readAt,
createdAt: msg.createdAt, createdAt: msg.createdAt,
@@ -157,7 +74,7 @@ export async function GET(request: NextRequest) {
}); });
return NextResponse.json({ return NextResponse.json({
messages: messagesWithDecryption.reverse(), // Oldest first for display messages: messagesMapped.reverse(), // Oldest first for display
nextCursor: messages.length === limit ? messages[messages.length - 1].createdAt.toISOString() : null, nextCursor: messages.length === limit ? messages[messages.length - 1].createdAt.toISOString() : null,
}); });
} catch (error) { } catch (error) {
-346
View File
@@ -1,346 +0,0 @@
/**
* Swarm Chat Send
*
* POST: Send a chat message to another user (local or remote)
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, users, chatConversations, chatMessages } from '@/db';
import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
import { getSession } from '@/lib/auth';
import { encryptMessage } from '@/lib/swarm/chat-crypto';
import type { SwarmChatMessagePayload } from '@/lib/swarm/chat-types';
const sendMessageSchema = z.object({
recipientHandle: z.string(),
// For E2E encryption: client sends pre-encrypted content
encryptedContent: z.string().optional(),
senderPublicKey: z.string().optional(), // ECDH public key for decryption
// Legacy: server-side encryption (will be removed)
content: z.string().min(1).max(5000).optional(),
}).refine(data => data.encryptedContent || data.content, {
message: 'Either encryptedContent or content is required',
});
export async function POST(request: NextRequest) {
console.log('[Chat Send] Starting request processing');
try {
if (!db) {
console.error('[Chat Send] Database connection missing');
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const session = await getSession();
if (!session?.user) {
console.warn('[Chat Send] Unauthorized attempt');
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.log('[Chat Send] User authenticated:', session.user.id);
let body;
try {
body = await request.json();
} catch (e) {
console.error('[Chat Send] Failed to parse JSON body');
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const parseResult = sendMessageSchema.safeParse(body);
if (!parseResult.success) {
console.error('[Chat Send] Schema validation failed:', parseResult.error);
return NextResponse.json({ error: 'Invalid input', details: parseResult.error.issues }, { status: 400 });
}
const data = parseResult.data;
console.log('[Chat Send] Input validated. Recipient:', data.recipientHandle, 'Has senderPublicKey:', !!data.senderPublicKey);
// Get sender info
const sender = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
});
if (!sender) {
console.error('[Chat Send] Sender not found in DB:', session.user.id);
return NextResponse.json({ error: 'Sender not found' }, { status: 404 });
}
console.log('[Chat Send] Sender retrieved:', sender.handle);
// Parse recipient handle (could be local or remote)
// Strip leading @ if present, then normalize
const recipientHandle = data.recipientHandle.toLowerCase().replace(/^@/, '');
const isRemote = recipientHandle.includes('@');
let recipientUser: typeof users.$inferSelect | undefined;
let recipientPublicKey: string;
let recipientNodeDomain: string | null = null;
if (isRemote) {
// Remote user - need to fetch their public key
// Format: handle@domain (e.g., matterbator@batorbros.bond)
const atIndex = recipientHandle.indexOf('@');
const handle = recipientHandle.substring(0, atIndex);
const domain = recipientHandle.substring(atIndex + 1);
recipientNodeDomain = domain;
console.log('[Chat Send] Processing remote recipient:', handle, '@', domain);
// Try to find cached remote user
recipientUser = await db.query.users.findFirst({
where: eq(users.handle, recipientHandle),
});
// Check if we have a valid public key (not a placeholder)
const hasValidPublicKey = recipientUser?.publicKey?.startsWith('-----BEGIN');
if (!recipientUser || !hasValidPublicKey) {
// Fetch from remote node to get the real public key
try {
console.log('[Chat Send] Fetching remote user from node:', domain);
const protocol = domain.includes('localhost') ? 'http' : 'https';
const response = await fetch(`${protocol}://${domain}/api/users/${handle}`);
if (!response.ok) {
console.error('[Chat Send] Remote user fetch failed. Status:', response.status);
return NextResponse.json({ error: 'Recipient not found' }, { status: 404 });
}
const remoteUserData = await response.json();
const userData = remoteUserData.user || remoteUserData;
recipientPublicKey = userData.publicKey;
if (!recipientPublicKey || !recipientPublicKey.startsWith('-----BEGIN')) {
console.error('[Chat Send] Remote user has no valid public key');
return NextResponse.json({ error: 'Recipient does not support encrypted chat' }, { status: 400 });
}
if (recipientUser) {
// Update existing cached user with real public key
await db.update(users)
.set({ publicKey: recipientPublicKey })
.where(eq(users.id, recipientUser.id));
console.log('[Chat Send] Updated cached user with real public key');
} else {
// Cache the remote user
const [newUser] = await db.insert(users).values({
did: userData.did || `did:swarm:${domain}:${handle}`,
handle: recipientHandle,
displayName: userData.displayName,
avatarUrl: userData.avatarUrl,
publicKey: recipientPublicKey,
}).returning();
recipientUser = newUser;
console.log('[Chat Send] Remote user cached');
}
} catch (error) {
console.error('[Chat Send] Failed to fetch remote user:', error);
return NextResponse.json({ error: 'Failed to reach recipient node' }, { status: 503 });
}
} else {
recipientPublicKey = recipientUser.publicKey;
console.log('[Chat Send] Remote user found in cache with valid key');
}
} else {
// Local user
console.log('[Chat Send] Processing local recipient');
recipientUser = await db.query.users.findFirst({
where: eq(users.handle, recipientHandle),
});
if (!recipientUser) {
console.warn('[Chat Send] Local recipient not found:', recipientHandle);
return NextResponse.json({ error: 'Recipient not found' }, { status: 404 });
}
if (recipientUser.isSuspended) {
console.warn('[Chat Send] Local recipient suspended:', recipientHandle);
return NextResponse.json({ error: 'Recipient not available' }, { status: 404 });
}
recipientPublicKey = recipientUser.publicKey;
}
// Handle encryption - either client-side (E2E) or server-side (legacy)
let encryptedContent: string;
let senderEncryptedContent: string | null = null;
if (data.encryptedContent) {
// E2E mode: client already encrypted the message
// Server cannot read the content - this is true E2E encryption
console.log('[Chat Send] Using client-side E2E encryption');
encryptedContent = data.encryptedContent;
// Store sender's public key so recipient can decrypt
// Note: senderEncryptedContent not needed in E2E mode - client stores locally
} else if (data.content) {
// Legacy mode: server-side encryption (for backwards compatibility)
console.log('[Chat Send] Using server-side encryption (legacy)');
try {
encryptedContent = encryptMessage(data.content, recipientPublicKey);
senderEncryptedContent = encryptMessage(data.content, sender.publicKey);
} catch (encError) {
console.error('[Chat Send] Encryption failed:', encError);
return NextResponse.json({ error: 'Encryption failed' }, { status: 500 });
}
} else {
return NextResponse.json({ error: 'No message content provided' }, { status: 400 });
}
// Get or create conversation
let conversation = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, sender.id),
eq(chatConversations.participant2Handle, recipientHandle)
),
});
if (!conversation) {
console.log('[Chat Send] Creating new conversation');
const [newConversation] = await db.insert(chatConversations).values({
participant1Id: sender.id,
participant2Handle: recipientHandle,
lastMessageAt: new Date(),
lastMessagePreview: 'New message',
}).returning();
conversation = newConversation;
}
// Store the message locally
const messageId = crypto.randomUUID();
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
const swarmMessageId = `swarm:${nodeDomain}:${messageId}`;
console.log('[Chat Send] Inserting message into DB, senderPublicKey from client:', !!data.senderPublicKey, 'from DB:', !!sender.chatPublicKey);
const [newMessage] = await db.insert(chatMessages).values({
conversationId: conversation.id,
senderHandle: sender.handle,
senderDisplayName: sender.displayName,
senderAvatarUrl: sender.avatarUrl,
senderNodeDomain: null, // Local sender
encryptedContent,
senderEncryptedContent,
// Use client-provided key first, fall back to database
senderChatPublicKey: data.senderPublicKey || sender.chatPublicKey,
swarmMessageId,
deliveredAt: isRemote ? null : new Date(), // Delivered immediately if local
readAt: null,
}).returning();
// Update conversation
await db.update(chatConversations)
.set({
lastMessageAt: new Date(),
lastMessagePreview: 'New message',
updatedAt: new Date(),
})
.where(eq(chatConversations.id, conversation.id));
// For LOCAL recipients, create/update their conversation too
if (!isRemote && recipientUser) {
try {
console.log('[Chat Send] Creating reciprocal conversation for local recipient:', recipientUser.handle);
// Check if recipient has a conversation with sender
let recipientConversation = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, recipientUser.id),
eq(chatConversations.participant2Handle, sender.handle)
),
});
if (!recipientConversation) {
// Create conversation for recipient
const [newRecipientConv] = await db.insert(chatConversations).values({
participant1Id: recipientUser.id,
participant2Handle: sender.handle,
lastMessageAt: new Date(),
lastMessagePreview: 'New message',
}).returning();
recipientConversation = newRecipientConv;
console.log('[Chat Send] Created new conversation for recipient');
} else {
// Update existing conversation
await db.update(chatConversations)
.set({
lastMessageAt: new Date(),
lastMessagePreview: 'New message',
updatedAt: new Date(),
})
.where(eq(chatConversations.id, recipientConversation.id));
console.log('[Chat Send] Updated existing conversation for recipient');
}
// Insert message into recipient's conversation
await db.insert(chatMessages).values({
conversationId: recipientConversation.id,
senderHandle: sender.handle,
senderDisplayName: sender.displayName,
senderAvatarUrl: sender.avatarUrl,
senderNodeDomain: null,
encryptedContent,
senderEncryptedContent,
senderChatPublicKey: data.senderPublicKey || sender.chatPublicKey,
swarmMessageId: `${swarmMessageId}-recipient`, // Make it unique for recipient's copy
deliveredAt: new Date(),
readAt: null,
});
console.log('[Chat Send] Reciprocal message created for local recipient');
} catch (recipError) {
console.error('[Chat Send] Failed to create reciprocal conversation:', recipError);
// Don't fail the whole request - sender's message was still saved
}
}
// If remote, send to their node
if (isRemote && recipientNodeDomain) {
// ... (remote logic remains similar but add logs)
console.log('[Chat Send] Dispatching to remote node:', recipientNodeDomain);
// ... existing remote send logic ...
// For brevity in this tool call, I'm keeping the original logic mostly intact but wrapped/logged.
// Re-implementing the block:
try {
const payload: SwarmChatMessagePayload = {
messageId,
senderHandle: sender.handle,
senderDisplayName: sender.displayName || undefined,
senderAvatarUrl: sender.avatarUrl || undefined,
senderNodeDomain: nodeDomain,
recipientHandle: recipientHandle.split('@')[0],
encryptedContent,
timestamp: new Date().toISOString(),
};
const protocol = recipientNodeDomain.includes('localhost') ? 'http' : 'https';
const response = await fetch(`${protocol}://${recipientNodeDomain}/api/swarm/chat/inbox`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (response.ok) {
// Mark as delivered
await db.update(chatMessages)
.set({ deliveredAt: new Date() })
.where(eq(chatMessages.id, newMessage.id));
console.log('[Chat Send] Remote delivery confirmed');
} else {
console.warn('[Chat Send] Remote delivery failed. Status:', response.status);
}
} catch (error) {
console.error('[Chat Send] Failed to send message to remote node:', error);
// Message is still stored locally, will show as undelivered
}
}
console.log('[Chat Send] Success');
return NextResponse.json({
success: true,
message: newMessage,
});
} catch (error) {
if (error instanceof z.ZodError) {
// Should be caught by safeParse above, but just in case
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
}
console.error('[Chat Send] Unhandled error:', error);
return NextResponse.json({ error: 'Failed to send message' }, { status: 500 });
}
}
+2 -2
View File
@@ -22,7 +22,7 @@ export interface SwarmUserProfile {
isBot?: boolean; isBot?: boolean;
botOwnerHandle?: string; // Handle of the bot's owner (e.g., "user" or "user@domain") botOwnerHandle?: string; // Handle of the bot's owner (e.g., "user" or "user@domain")
nodeDomain: string; nodeDomain: string;
chatPublicKey?: string; publicKey?: string; // Signing key for verifying actions
did?: string; did?: string;
} }
@@ -93,7 +93,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
isBot: user.isBot || undefined, isBot: user.isBot || undefined,
botOwnerHandle: user.isBot && user.botOwner ? user.botOwner.handle : undefined, botOwnerHandle: user.isBot && user.botOwner ? user.botOwner.handle : undefined,
nodeDomain, nodeDomain,
chatPublicKey: user.chatPublicKey || undefined, publicKey: user.publicKey, // Expose signing key
did: user.did || undefined, did: user.did || undefined,
}; };
+32 -5
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db, users } from '@/db'; import { eq, and } from 'drizzle-orm';
import { eq } from 'drizzle-orm'; import { getSession } from '@/lib/auth';
import { db, users, follows } from '@/db';
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions'; import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
type RouteContext = { params: Promise<{ handle: string }> }; type RouteContext = { params: Promise<{ handle: string }> };
@@ -62,7 +63,6 @@ export async function GET(request: Request, context: RouteContext) {
isSwarm: true, isSwarm: true,
nodeDomain: remoteDomain, nodeDomain: remoteDomain,
isBot: profile.isBot || false, isBot: profile.isBot || false,
chatPublicKey: profile.chatPublicKey,
did: profile.did, did: profile.did,
} }
}); });
@@ -96,11 +96,38 @@ export async function GET(request: Request, context: RouteContext) {
website: user.website, website: user.website,
movedTo: user.movedTo, movedTo: user.movedTo,
isBot: user.isBot, isBot: user.isBot,
publicKey: user.publicKey, // RSA key for signing publicKey: user.publicKey, // Signing key
chatPublicKey: user.chatPublicKey, // ECDH key for E2E chat
did: user.did, // V2 Identity did: user.did, // V2 Identity
dmPrivacy: user.dmPrivacy,
}; };
// Check if viewer can DM this user
let canReceiveDms = true;
if (user.isBot) {
canReceiveDms = false;
} else if (user.dmPrivacy === 'none') {
canReceiveDms = false;
} else if (user.dmPrivacy === 'following') {
canReceiveDms = false; // Default to false for 'following'
const session = await getSession();
if (session?.user) {
if (session.user.id === user.id) {
canReceiveDms = true; // Can DM yourself
} else {
const isFollowingViewer = await db.query.follows.findFirst({
where: and(
eq(follows.followerId, user.id),
eq(follows.followingId, session.user.id)
)
});
if (isFollowingViewer) {
canReceiveDms = true;
}
}
}
}
userResponse.canReceiveDms = canReceiveDms;
// If this is a bot, include owner info // If this is a bot, include owner info
if (user.isBot && user.botOwnerId) { if (user.isBot && user.botOwnerId) {
const owner = await db.query.users.findFirst({ const owner = await db.query.users.findFirst({
@@ -299,7 +299,7 @@ export default function EditBotPage() {
} }
} }
router.push(`/settings/bots/${botId}`); router.push(`/bots/${botId}`);
} catch (err) { } catch (err) {
console.error('Update bot error:', err); console.error('Update bot error:', err);
setError(err instanceof Error ? err.message : 'Failed to update bot'); setError(err instanceof Error ? err.message : 'Failed to update bot');
@@ -252,7 +252,7 @@ export default function BotDetailPage() {
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '24px 16px 64px' }}> <div style={{ maxWidth: '800px', margin: '0 auto', padding: '24px 16px 64px' }}>
<div className="card" style={{ padding: '48px 24px', textAlign: 'center' }}> <div className="card" style={{ padding: '48px 24px', textAlign: 'center' }}>
<p style={{ color: 'var(--foreground-tertiary)' }}>Bot not found</p> <p style={{ color: 'var(--foreground-tertiary)' }}>Bot not found</p>
<Link href="/settings/bots" className="btn" style={{ marginTop: '16px' }}> <Link href="/bots" className="btn" style={{ marginTop: '16px' }}>
Back to Bots Back to Bots
</Link> </Link>
</div> </div>
@@ -270,7 +270,7 @@ export default function BotDetailPage() {
return ( return (
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '24px 16px 64px' }}> <div style={{ maxWidth: '800px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '32px' }}> <header style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '32px' }}>
<Link href="/settings/bots" style={{ color: 'var(--foreground)' }}> <Link href="/bots" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon /> <ArrowLeftIcon />
</Link> </Link>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
@@ -331,7 +331,7 @@ export default function BotDetailPage() {
{bot.isActive ? 'Deactivate' : 'Activate'} {bot.isActive ? 'Deactivate' : 'Activate'}
</button> </button>
<Link <Link
href={`/settings/bots/${botId}/edit`} href={`/bots/${botId}/edit`}
className="btn" className="btn"
> >
<Pencil size={16} /> <Pencil size={16} />
@@ -716,7 +716,7 @@ export default function BotDetailPage() {
onClick={() => { onClick={() => {
if (confirm(`Are you sure you want to delete ${bot.name}? This cannot be undone.`)) { if (confirm(`Are you sure you want to delete ${bot.name}? This cannot be undone.`)) {
fetch(`/api/bots/${botId}`, { method: 'DELETE' }) fetch(`/api/bots/${botId}`, { method: 'DELETE' })
.then(() => router.push('/settings/bots')) .then(() => router.push('/bots'))
.catch(() => showToast('Failed to delete bot', 'error')); .catch(() => showToast('Failed to delete bot', 'error'));
} }
}} }}
@@ -242,7 +242,7 @@ export default function NewBotPage() {
}); });
} }
router.push(`/settings/bots/${data.bot.id}`); router.push(`/bots/${data.bot.id}`);
} else { } else {
const data = await response.json(); const data = await response.json();
console.error('Bot creation failed:', data); console.error('Bot creation failed:', data);
@@ -845,7 +845,7 @@ export default function NewBotPage() {
return ( return (
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '24px 16px 64px' }}> <div style={{ maxWidth: '700px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '32px' }}> <header style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '32px' }}>
<Link href="/settings/bots" style={{ color: 'var(--foreground)' }}> <Link href="/bots" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon /> <ArrowLeftIcon />
</Link> </Link>
<div> <div>
+217
View File
@@ -0,0 +1,217 @@
/**
* Bot Management Page
*
* Lists user's bots and provides creation interface.
*
* Requirements: 1.3
*/
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { Bot, Plus, Sparkles } from 'lucide-react';
interface BotData {
id: string;
name: string;
handle: string;
bio: string;
avatarUrl: string | null;
isActive: boolean;
isSuspended: boolean;
autonomousMode: boolean;
lastPostAt: Date | null;
createdAt: Date;
}
export default function BotsPage() {
const router = useRouter();
const [bots, setBots] = useState<BotData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchBots();
}, []);
const fetchBots = async () => {
try {
const response = await fetch('/api/bots');
if (response.ok) {
const data = await response.json();
setBots(data.bots || []);
}
} catch (error) {
console.error('Failed to fetch bots:', error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '24px 16px 64px' }}>
<div style={{ textAlign: 'center', padding: '48px 24px', color: 'var(--foreground-tertiary)' }}>
Loading...
</div>
</div>
);
}
return (
<>
<header style={{
padding: '16px',
borderBottom: '1px solid var(--border)',
position: 'sticky',
top: 0,
background: 'var(--background)',
zIndex: 10,
backdropFilter: 'blur(12px)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h1 style={{ fontSize: '18px', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Bot size={18} />
Bots
</h1>
<Link href="/bots/new" className="btn btn-primary btn-sm">
<Plus size={16} />
Create
</Link>
</div>
</header>
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '24px 16px 64px' }}>
{bots.length === 0 ? (
<div className="card" style={{ padding: '48px 24px', textAlign: 'center' }}>
<Bot size={48} style={{ margin: '0 auto 16px', color: 'var(--foreground-tertiary)', opacity: 0.5 }} />
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '8px' }}>
No bots yet
</h2>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px', marginBottom: '24px' }}>
Create your first bot to start automating posts and interactions
</p>
<Link href="/bots/new" className="btn btn-primary">
<Plus size={18} />
Create Your First Bot
</Link>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{bots.map((bot) => (
<div
key={bot.id}
className="card"
style={{
padding: '20px',
cursor: 'pointer',
transition: 'border-color 0.15s ease',
}}
onClick={() => router.push(`/bots/${bot.id}`)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '12px' }}>
<div style={{ display: 'flex', gap: '12px', flex: 1, minWidth: 0 }}>
<Link
href={`/u/${bot.handle}`}
onClick={(e) => e.stopPropagation()}
className="avatar"
style={{
width: '48px',
height: '48px',
flexShrink: 0,
fontSize: '18px',
}}
>
{bot.avatarUrl ? (
<img src={bot.avatarUrl} alt={bot.name} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }} />
) : (
bot.name.charAt(0).toUpperCase()
)}
</Link>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600 }}>{bot.name}</h2>
{bot.autonomousMode && (
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
fontSize: '11px',
padding: '3px 8px',
borderRadius: 'var(--radius-full)',
background: 'var(--accent-muted)',
color: 'var(--accent)',
}}>
<Sparkles size={12} />
Auto
</span>
)}
</div>
<Link
href={`/u/${bot.handle}`}
onClick={(e) => e.stopPropagation()}
style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}
>
@{bot.handle}
</Link>
{bot.bio && (
<p style={{
fontSize: '13px',
color: 'var(--foreground-secondary)',
marginTop: '8px',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}>
{bot.bio}
</p>
)}
</div>
</div>
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
{bot.isSuspended ? (
<span className="status-pill suspended">
Suspended
</span>
) : bot.isActive ? (
<span className="status-pill active">
Active
</span>
) : (
<span className="status-pill">
Inactive
</span>
)}
</div>
</div>
<div style={{
display: 'flex',
gap: '16px',
fontSize: '12px',
color: 'var(--foreground-tertiary)',
paddingTop: '12px',
borderTop: '1px solid var(--border)',
}}>
<span>
Last post: {bot.lastPostAt
? new Date(bot.lastPostAt).toLocaleDateString()
: 'Never'}
</span>
<span>
Created: {new Date(bot.createdAt).toLocaleDateString()}
</span>
</div>
</div>
))}
</div>
)}
</div>
</>
);
}
+87 -228
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
import { useSodiumChat } from '@/lib/hooks/useSodiumChat'; import { signedAPI } from '@/lib/api/signed-fetch';
import { ArrowLeft, Send, Lock, Shield, Loader2, MessageCircle, Search, Plus, Trash2, MoreVertical } from 'lucide-react'; import { ArrowLeft, Send, Lock, Shield, Loader2, MessageCircle, Search, Plus, Trash2, MoreVertical } from 'lucide-react';
import { formatFullHandle } from '@/lib/utils/handle'; import { formatFullHandle } from '@/lib/utils/handle';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
@@ -21,15 +21,14 @@ interface Conversation {
unreadCount: number; unreadCount: number;
} }
interface Message { interface Message {
id: string; id: string;
senderHandle: string; senderHandle: string;
senderDisplayName?: string; senderDisplayName?: string;
senderAvatarUrl?: string; senderAvatarUrl?: string;
senderDid?: string; // V2 needs DID senderDid?: string;
senderPublicKey?: string; // Legacy content: string;
encryptedContent: string;
decryptedContent?: string;
isSentByMe: boolean; isSentByMe: boolean;
deliveredAt?: string; deliveredAt?: string;
readAt?: string; readAt?: string;
@@ -39,8 +38,6 @@ interface Message {
export default function ChatPage() { export default function ChatPage() {
const { user, isIdentityUnlocked, setShowUnlockPrompt } = useAuth(); const { user, isIdentityUnlocked, setShowUnlockPrompt } = useAuth();
const router = useRouter(); const router = useRouter();
// Libsodium E2EE Hook
const { isReady, status, sendMessage, decryptMessage } = useSodiumChat();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const composeHandle = searchParams.get('compose'); const composeHandle = searchParams.get('compose');
@@ -49,20 +46,11 @@ export default function ChatPage() {
const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null); const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null);
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [newMessage, setNewMessage] = useState(''); const [newMessage, setNewMessage] = useState('');
const [newChatHandle, setNewChatHandle] = useState('');
const [showNewChat, setShowNewChat] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [loadingMessages, setLoadingMessages] = useState(false); const [loadingMessages, setLoadingMessages] = useState(false);
// Cache for decrypted messages to avoid re-decrypting on every poll
const decryptedCacheRef = useRef<Map<string, string>>(new Map());
// Track which messages we've attempted to decrypt (even if they failed)
const attemptedDecryptionRef = useRef<Set<string>>(new Set());
// Track the current conversation ID to prevent race conditions
const currentConversationIdRef = useRef<string | null>(null);
// Legacy / V2 Hybrid State // Legacy / V2 Hybrid State
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
const [conversationToDelete, setConversationToDelete] = useState<Conversation | null>(null); const [conversationToDelete, setConversationToDelete] = useState<Conversation | null>(null);
@@ -96,12 +84,14 @@ export default function ChatPage() {
} }
}; };
const loadConversations = async (isInitialLoad = false) => { const loadConversations = async (isInitialLoad = true) => {
if (isInitialLoad) setLoading(true);
try { try {
if (isInitialLoad) setLoading(true);
const res = await fetch('/api/swarm/chat/conversations'); const res = await fetch('/api/swarm/chat/conversations');
if (res.ok) {
const data = await res.json(); const data = await res.json();
setConversations(data.conversations || []); setConversations(data.conversations || []);
}
} catch (e) { } catch (e) {
console.error("Failed to load conversations", e); console.error("Failed to load conversations", e);
} finally { } finally {
@@ -114,81 +104,23 @@ export default function ChatPage() {
const res = await fetch(`/api/swarm/chat/messages?conversationId=${conversationId}`); const res = await fetch(`/api/swarm/chat/messages?conversationId=${conversationId}`);
const data = await res.json(); const data = await res.json();
const decrypted = await Promise.all((data.messages || []).map(async (msg: any) => { const plainMessages = (data.messages || []).map((msg: any) => ({
try { ...msg,
// Check cache first content: msg.content || '[Empty Message]'
const cacheKey = `${msg.id}`;
const cached = decryptedCacheRef.current.get(cacheKey);
if (cached) {
return { ...msg, decryptedContent: cached };
}
// Check if already attempted
if (attemptedDecryptionRef.current.has(cacheKey)) {
const fallback = decryptedCacheRef.current.get(cacheKey) || '🔒 [Encrypted]';
return { ...msg, decryptedContent: fallback };
}
// Mark as attempted
attemptedDecryptionRef.current.add(cacheKey);
// Parse libsodium message format
if (msg.encryptedContent && msg.encryptedContent.startsWith('{')) {
try {
const envelope = JSON.parse(msg.encryptedContent);
// Libsodium format: {senderPublicKey, recipientDid, ciphertext, nonce}
if (envelope.senderPublicKey && envelope.ciphertext && envelope.nonce) {
// For decryption with crypto_box_open_easy:
// - We need the OTHER party's public key
// - We use OUR private key
// If I sent this message, the "other party" is the recipient
// If I received this message, the "other party" is the sender
let otherPartyPublicKey = envelope.senderPublicKey;
if (msg.isSentByMe && envelope.recipientDid) {
// I'm the sender, so I need the recipient's public key to decrypt my own message
try {
const keyRes = await fetch(`/api/chat/keys?did=${encodeURIComponent(envelope.recipientDid)}`, { cache: 'no-store' });
if (keyRes.ok) {
const keyData = await keyRes.json();
otherPartyPublicKey = keyData.publicKey;
}
} catch (e) {
console.error('[Chat UI] Failed to fetch recipient key:', e);
}
} else {
// Using sender public key from envelope
}
const plaintext = await decryptMessage(
envelope.ciphertext,
envelope.nonce,
otherPartyPublicKey
);
decryptedCacheRef.current.set(cacheKey, plaintext);
return { ...msg, decryptedContent: plaintext };
}
} catch (e) {
console.error('[Chat UI] Libsodium decryption failed:', e);
}
}
// Fallback
const fallback = '🔒 [Encrypted - refresh page]';
decryptedCacheRef.current.set(cacheKey, fallback);
return { ...msg, decryptedContent: fallback };
} catch (err) {
console.error('[Chat UI] Message processing error:', err);
return { ...msg, decryptedContent: '[Error]' };
}
})); }));
setMessages(decrypted); // Only update if different
} catch (err) { setMessages(prev => {
console.error('[Chat UI] Load messages error:', err); const prevIds = prev.map(m => m.id).join(',');
const newIds = plainMessages.map((m: any) => m.id).join(',');
if (prevIds === newIds && prev.length === plainMessages.length) return prev;
return plainMessages;
});
// Mark as read
markAsRead(conversationId);
} catch (e) {
console.error("Failed to load messages", e);
} }
}; };
@@ -218,8 +150,18 @@ export default function ChatPage() {
if (!did) throw new Error('User not found'); if (!did) throw new Error('User not found');
} }
// Send using Signal Protocol if (!user.did) throw new Error('User DID missing');
await sendMessage(did, newMessage, selectedConversation.participant2.handle);
// Send using Signed API
await signedAPI.sendChat(
did,
selectedConversation.participant2.handle,
newMessage,
user.did,
user.handle
);
setNewMessage(''); setNewMessage('');
@@ -252,71 +194,6 @@ export default function ChatPage() {
} }
}; };
const startNewChat = async (e: React.FormEvent) => {
e.preventDefault();
if (!newChatHandle.trim()) return;
setSending(true);
try {
let cleanHandle = newChatHandle.replace(/^@/, '');
// If the handle includes a domain, check if it's our local domain
if (cleanHandle.includes('@')) {
const [handle, domain] = cleanHandle.split('@');
const localDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || window.location.host;
// If it's our local domain, strip it for the API call
if (domain === localDomain) {
cleanHandle = handle;
}
}
const res = await fetch(`/api/users/${encodeURIComponent(cleanHandle)}`);
const data = await res.json();
if (!data.user?.did) {
alert('User not found or Olm encryption not enabled.');
setSending(false);
return;
}
// Check if existing conversation
const existing = conversations.find(c =>
c.participant2.handle.toLowerCase() === data.user.handle.toLowerCase()
);
if (existing) {
setSelectedConversation(existing);
} else {
// Setup draft
const draftConv: Conversation = {
id: 'new',
participant2: {
handle: data.user.handle,
displayName: data.user.displayName || data.user.handle,
avatarUrl: data.user.avatarUrl,
did: data.user.did
},
lastMessageAt: new Date().toISOString(),
lastMessagePreview: 'New Conversation',
unreadCount: 0
};
setSelectedConversation(draftConv);
}
setShowNewChat(false);
setNewChatHandle('');
} catch (e: any) {
console.error('[Chat UI] Start chat failed:', e);
if (e.message.includes('Recipient keys not found') || e.message.includes('Failed to fetch recipient keys')) {
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);
}
};
const handleDeleteConversation = async (deleteFor: 'self' | 'both') => { const handleDeleteConversation = async (deleteFor: 'self' | 'both') => {
if (!conversationToDelete) return; if (!conversationToDelete) return;
setIsDeleting(true); setIsDeleting(true);
@@ -344,9 +221,10 @@ export default function ChatPage() {
// EFFECTS (Now that functions are defined) // EFFECTS (Now that functions are defined)
// ============================================ // ============================================
// Load conversations
// Load conversations // Load conversations
useEffect(() => { useEffect(() => {
if (user && isReady) { if (user) {
loadConversations(true); // Initial load with spinner loadConversations(true); // Initial load with spinner
// Poll for new conversations every 5 seconds (no spinner) // Poll for new conversations every 5 seconds (no spinner)
@@ -356,11 +234,11 @@ export default function ChatPage() {
return () => clearInterval(pollInterval); return () => clearInterval(pollInterval);
} }
}, [user, isReady]); }, [user]);
// Handle Compose Intent // Handle Compose Intent
useEffect(() => { useEffect(() => {
if (composeHandle && isReady && !selectedConversation && conversations.length >= 0) { if (composeHandle && !selectedConversation && conversations.length >= 0) {
// Check if we already have a conversation with this user // Check if we already have a conversation with this user
const existing = conversations.find(c => const existing = conversations.find(c =>
c.participant2.handle.toLowerCase() === composeHandle.toLowerCase() c.participant2.handle.toLowerCase() === composeHandle.toLowerCase()
@@ -377,6 +255,11 @@ export default function ChatPage() {
const res = await fetch(`/api/users/${encodeURIComponent(composeHandle)}`); const res = await fetch(`/api/users/${encodeURIComponent(composeHandle)}`);
const data = await res.json(); const data = await res.json();
if (data.user) { if (data.user) {
if (data.user.isBot || data.user.canReceiveDms === false) {
console.error('Cannot DM this account due to privacy settings');
router.replace('/chat');
return;
}
const draftConv: Conversation = { const draftConv: Conversation = {
id: 'new', id: 'new',
participant2: { participant2: {
@@ -404,7 +287,7 @@ export default function ChatPage() {
fetchUserAndInitDraft(); fetchUserAndInitDraft();
} }
} }
}, [composeHandle, isReady, selectedConversation, conversations, loading, router]); }, [composeHandle, selectedConversation, conversations, loading, router]);
// Redirect if not logged in // Redirect if not logged in
useEffect(() => { useEffect(() => {
@@ -415,9 +298,8 @@ export default function ChatPage() {
// Load messages when conversation is selected // Load messages when conversation is selected
useEffect(() => { useEffect(() => {
if (selectedConversation && isReady) { if (selectedConversation) {
// Update current conversation ref
currentConversationIdRef.current = selectedConversation.id;
// Clear messages immediately to prevent flash // Clear messages immediately to prevent flash
setMessages([]); setMessages([]);
@@ -435,7 +317,7 @@ export default function ChatPage() {
// Poll for new messages every 3 seconds // Poll for new messages every 3 seconds
const pollInterval = setInterval(() => { const pollInterval = setInterval(() => {
// Only load if still the same conversation // Only load if still the same conversation
if (currentConversationIdRef.current === selectedConversation.id && selectedConversation.id !== 'new') { if (selectedConversation.id !== 'new') {
loadMessages(selectedConversation.id); loadMessages(selectedConversation.id);
} }
}, 3000); }, 3000);
@@ -443,11 +325,11 @@ export default function ChatPage() {
return () => clearInterval(pollInterval); return () => clearInterval(pollInterval);
} else if (!selectedConversation) { } else if (!selectedConversation) {
// Clear messages when no conversation selected // Clear messages when no conversation selected
currentConversationIdRef.current = null;
setMessages([]); setMessages([]);
setLoadingMessages(false); setLoadingMessages(false);
} }
}, [selectedConversation, isReady]); }, [selectedConversation]);
// Auto-scroll to bottom of messages only if user was already at bottom // Auto-scroll to bottom of messages only if user was already at bottom
useEffect(() => { useEffect(() => {
@@ -471,10 +353,10 @@ export default function ChatPage() {
if (!isIdentityUnlocked) { if (!isIdentityUnlocked) {
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', alignItems: 'center', justifyContent: 'center', gap: '16px', padding: '24px' }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100vh', alignItems: 'center', justifyContent: 'center', gap: '16px', padding: '24px' }}>
<Lock size={48} style={{ color: 'rgb(251, 191, 36)' }} /> <Lock size={48} style={{ color: 'var(--accent)' }} />
<h2 style={{ fontSize: '20px', fontWeight: 600 }}>Identity Locked</h2> <h2 style={{ fontSize: '20px', fontWeight: 600 }}>Identity Required</h2>
<p style={{ color: 'var(--foreground-secondary)', maxWidth: '400px', textAlign: 'center' }}> <p style={{ color: 'var(--foreground-secondary)', maxWidth: '400px', textAlign: 'center' }}>
End-to-end encrypted chat requires your identity to be unlocked. Your private keys are needed to encrypt and decrypt messages. Chat requires your identity to be unlocked. Your private keys are used to sign messages to prove they came from you.
</p> </p>
<button <button
onClick={() => setShowUnlockPrompt(true)} onClick={() => setShowUnlockPrompt(true)}
@@ -488,33 +370,7 @@ export default function ChatPage() {
); );
} }
// Error State
if (status === 'error') {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', alignItems: 'center', justifyContent: 'center', gap: '16px' }}>
<Shield size={48} style={{ color: 'var(--destructive)' }} />
<h2 style={{ fontSize: '20px', fontWeight: 600 }}>Connection Failed</h2>
<p style={{ color: 'var(--foreground-secondary)', maxWidth: '300px', textAlign: 'center' }}>
Unable to initialize secure chat. Please refresh the page to try again.
</p>
<button
onClick={() => window.location.reload()}
className="btn btn-primary"
>
Refresh Page
</button>
</div>
);
}
// Loading State
if (!isReady || status === 'initializing') {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', alignItems: 'center', justifyContent: 'center' }}>
<Loader2 className="animate-spin" size={32} />
</div>
);
}
// Prevent flash of list view while processing compose intent // Prevent flash of list view while processing compose intent
if (composeHandle && !selectedConversation) { if (composeHandle && !selectedConversation) {
@@ -530,15 +386,24 @@ export default function ChatPage() {
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', maxWidth: '600px', margin: '0 auto' }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100vh', maxWidth: '600px', margin: '0 auto' }}>
{/* Header */} {/* Header */}
<div className="post" style={{ position: 'sticky', top: 0, zIndex: 10, background: 'var(--background)', borderBottom: '1px solid var(--border)', flexShrink: 0 }}> <header style={{
position: 'sticky',
top: 0,
zIndex: 20,
background: 'rgba(10, 10, 10, 0.8)',
backdropFilter: 'blur(12px)',
borderBottom: '1px solid var(--border)',
padding: '12px 16px',
flexShrink: 0
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<button <button
onClick={() => setSelectedConversation(null)} onClick={() => setSelectedConversation(null)}
style={{ background: 'none', border: 'none', padding: '8px', cursor: 'pointer', color: 'var(--foreground)' }} style={{ background: 'none', border: 'none', padding: '4px', cursor: 'pointer', color: 'var(--foreground)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
> >
<ArrowLeft size={20} /> <ArrowLeft size={20} />
</button> </button>
<div className="avatar"> <div className="avatar" style={{ width: '32px', height: '32px', fontSize: '14px' }}>
{selectedConversation.participant2.avatarUrl ? ( {selectedConversation.participant2.avatarUrl ? (
<img src={selectedConversation.participant2.avatarUrl} alt="" /> <img src={selectedConversation.participant2.avatarUrl} alt="" />
) : ( ) : (
@@ -546,19 +411,19 @@ export default function ChatPage() {
)} )}
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600 }}>{selectedConversation.participant2.displayName}</div> <div style={{ fontWeight: 600, fontSize: '15px' }}>{selectedConversation.participant2.displayName}</div>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}> <div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{formatFullHandle(selectedConversation.participant2.handle)} {formatFullHandle(selectedConversation.participant2.handle)}
</div> </div>
</div> </div>
<button <button
onClick={() => { setConversationToDelete(selectedConversation); setShowDeleteModal(true); }} onClick={() => { setConversationToDelete(selectedConversation); setShowDeleteModal(true); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--foreground-tertiary)' }} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--foreground-tertiary)', padding: '4px' }}
> >
<Trash2 size={18} /> <Trash2 size={18} />
</button> </button>
</div> </div>
</div> </header>
{/* Messages */} {/* Messages */}
<div <div
@@ -598,7 +463,7 @@ export default function ChatPage() {
border: msg.isSentByMe ? 'none' : '1px solid var(--border)', border: msg.isSentByMe ? 'none' : '1px solid var(--border)',
wordBreak: 'break-word' wordBreak: 'break-word'
}}> }}>
{msg.decryptedContent || msg.encryptedContent} {msg.content}
</div> </div>
<div style={{ fontSize: '11px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}> <div style={{ fontSize: '11px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
@@ -685,30 +550,24 @@ export default function ChatPage() {
// LIST VIEW // LIST VIEW
return ( return (
<> <>
<div className="post" style={{ position: 'sticky', top: 0, zIndex: 10, background: 'var(--background)', borderBottom: '1px solid var(--border)' }}> <div style={{ position: 'sticky', top: 0, zIndex: 20, background: 'var(--background)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}> <header style={{
<h1 style={{ fontSize: '20px', fontWeight: 600, margin: 0 }}>Messages</h1> padding: '16px',
<button onClick={() => setShowNewChat(true)} className="btn btn-primary btn-sm"> borderBottom: '1px solid var(--border)',
<Plus size={16} style={{ marginRight: 6 }} /> New background: 'rgba(10, 10, 10, 0.8)',
</button> backdropFilter: 'blur(12px)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h1 style={{ fontSize: '18px', fontWeight: 600, margin: 0 }}>Chat</h1>
</div> </div>
</header>
{showNewChat ? ( <div style={{
<form onSubmit={startNewChat} style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> padding: '16px',
<input borderBottom: '1px solid var(--border)',
type="text" background: 'rgba(10, 10, 10, 0.8)',
placeholder="@username" backdropFilter: 'blur(12px)',
className="input" }}>
value={newChatHandle}
onChange={e => setNewChatHandle(e.target.value)}
autoFocus
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button type="button" onClick={() => setShowNewChat(false)} className="btn btn-ghost btn-sm">Cancel</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={sending}>Start</button>
</div>
</form>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', background: 'var(--background-secondary)', borderRadius: 'var(--radius-full)', padding: '8px 16px', border: '1px solid var(--border)' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px', background: 'var(--background-secondary)', borderRadius: 'var(--radius-full)', padding: '8px 16px', border: '1px solid var(--border)' }}>
<Search size={16} style={{ color: 'var(--foreground-tertiary)' }} /> <Search size={16} style={{ color: 'var(--foreground-tertiary)' }} />
<input <input
@@ -719,7 +578,7 @@ export default function ChatPage() {
onChange={e => setSearchQuery(e.target.value)} onChange={e => setSearchQuery(e.target.value)}
/> />
</div> </div>
)} </div>
</div> </div>
{loading ? ( {loading ? (
+17 -4
View File
@@ -287,9 +287,22 @@ export default function ExplorePage() {
return ( return (
<div className="explore-page"> <div className="explore-page">
<header className="explore-header"> <header style={{
<h1>Explore</h1> padding: '16px',
<form onSubmit={handleSearch} className="explore-search"> borderBottom: '1px solid var(--border)',
position: 'sticky',
top: 0,
background: 'rgba(10, 10, 10, 0.95)',
zIndex: 10,
backdropFilter: 'blur(12px)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h1 style={{ fontSize: '20px', fontWeight: 600 }}>Explore</h1>
</div>
</header>
<div style={{ padding: '0 16px' }}>
<form onSubmit={handleSearch} className="explore-search" style={{ marginTop: '16px' }}>
<SearchIcon /> <SearchIcon />
<input <input
type="text" type="text"
@@ -298,7 +311,7 @@ export default function ExplorePage() {
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
/> />
</form> </form>
</header> </div>
<div className="explore-tabs"> <div className="explore-tabs">
<button <button
+784 -21
View File
@@ -1301,36 +1301,799 @@ a.btn-primary:visited {
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar { /* Mobile-first optimizations */
position: fixed; html {
bottom: 0; height: 100%;
left: 0;
right: 0;
width: 100%;
height: auto;
border-right: none;
border-top: 1px solid var(--border);
background: var(--background);
z-index: 100;
padding: 8px 16px;
} }
.sidebar nav { body {
display: flex; font-size: 16px; /* Prevent zoom on input focus */
justify-content: space-around; overflow-x: hidden;
height: 100%;
overflow: auto;
-webkit-overflow-scrolling: touch;
} }
.nav-item span { /* Layout adjustments */
display: none; .layout {
} flex-direction: column;
max-width: 100%;
.logo { padding: 0;
display: none;
} }
.main { .main {
max-width: 100%;
width: 100%;
border-right: none;
padding-bottom: calc(70px + env(safe-area-inset-bottom));
min-height: 100vh;
order: 1;
}
/* Fixed bottom navigation - CRITICAL */
.sidebar {
position: fixed !important;
bottom: 0 !important;
top: auto !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
height: auto !important;
max-height: none !important;
border-right: none !important;
border-left: none !important;
border-bottom: none !important;
border-top: 1px solid var(--border) !important;
background: rgba(10, 10, 10, 0.98) !important;
backdrop-filter: blur(12px) !important;
-webkit-backdrop-filter: blur(12px) !important;
z-index: 1000 !important;
padding: 0 !important;
margin: 0 !important;
overflow-x: auto !important;
overflow-y: visible !important;
order: 2;
padding-bottom: env(safe-area-inset-bottom) !important;
}
.sidebar nav {
display: flex !important;
flex-direction: row !important;
justify-content: space-around !important;
align-items: center !important;
padding: 4px 0 !important;
min-height: 60px !important;
width: 100% !important;
margin: 0 !important;
}
.nav-item {
flex: 1 !important;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
gap: 4px !important;
padding: 8px 4px !important;
min-height: 56px !important;
font-size: 11px !important;
border-radius: 0 !important;
max-width: 100px !important;
min-width: 60px !important;
border: none !important;
background: transparent !important;
transition: none !important;
color: var(--foreground-secondary) !important;
}
.nav-item svg {
width: 24px !important;
height: 24px !important;
}
.nav-item span {
display: block !important;
font-size: 10px !important;
font-weight: 500 !important;
white-space: nowrap !important;
}
.nav-item:hover {
background: transparent !important;
border: none !important;
color: var(--foreground-secondary) !important;
}
.nav-item:active {
background: transparent !important;
border: none !important;
}
.nav-item.active {
background: transparent !important;
color: var(--accent) !important;
border: none !important;
}
.nav-item.active svg {
color: var(--accent) !important;
}
.nav-item.active span {
color: var(--accent) !important;
}
.logo {
display: none !important;
}
/* Hide sidebar user info on mobile */
.sidebar > div:not(nav) {
display: none !important;
}
.sidebar .sidebar-user-info {
display: none !important;
}
/* Show nav labels on mobile */
.nav-label {
display: inline !important;
}
/* Position notification dots correctly on mobile */
.notification-dot {
position: absolute !important;
top: -2px !important;
right: -6px !important;
}
/* Post optimizations */
.post {
padding: 16px;
}
.post-header {
gap: 10px;
}
.post-content {
font-size: 16px;
line-height: 1.5;
margin-bottom: 12px;
}
.post.detail .post-content {
font-size: 18px;
line-height: 1.6;
}
/* Larger touch targets for actions */
.post-actions {
gap: 16px;
margin-top: 12px;
}
.post-action {
padding: 8px;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: var(--radius-md);
transition: background 0.15s ease;
}
.post-action:active {
background: var(--background-tertiary);
}
.post-action svg {
width: 20px;
height: 20px;
}
/* Compose optimizations */
.compose {
padding: 16px;
}
.compose-input {
font-size: 16px;
min-height: 120px;
padding: 12px;
}
.compose-footer {
flex-wrap: wrap;
gap: 12px;
}
.compose-actions {
width: 100%;
justify-content: space-between;
}
.btn {
min-height: 44px;
padding: 10px 20px;
font-size: 15px;
}
.btn-primary {
flex: 0;
min-width: auto;
}
/* Avatar sizing */
.avatar {
width: 44px;
height: 44px;
font-size: 16px;
}
.avatar-sm {
width: 36px;
height: 36px;
font-size: 14px;
}
.avatar-lg {
width: 80px;
height: 80px;
font-size: 28px;
}
/* Media grid */
.post-media-grid {
grid-template-columns: 1fr;
gap: 8px;
}
.post-media-item img,
.post-media-item video {
max-height: 400px;
}
/* User rows */
.user-row {
padding: 16px;
}
.user-card {
padding: 16px;
}
/* Feed toggle */
.feed-toggle {
font-size: 14px;
}
.feed-toggle-btn {
padding: 8px 16px;
font-size: 14px;
}
/* Feed meta */
.feed-meta {
margin: 12px;
padding: 12px;
}
/* Header sticky positioning */
header {
position: sticky;
top: 0;
z-index: 50;
backdrop-filter: blur(12px);
background: rgba(10, 10, 10, 0.95);
}
header h1 {
font-size: 20px;
font-weight: 600;
}
/* Explore page */
.explore-header {
padding: 16px;
}
.explore-header h1 {
font-size: 20px;
}
.explore-search {
padding: 12px 16px;
}
.explore-search input {
font-size: 16px;
}
.explore-tab {
padding: 16px 12px;
font-size: 15px;
}
/* Settings and admin pages */
.install-shell,
.admin-shell,
.notifications-shell {
padding: 16px 12px 80px;
}
/* Input fields */
.input {
font-size: 16px;
padding: 12px 14px;
min-height: 44px;
}
/* Container */
.container {
padding: 0 12px;
}
/* Link preview */
.link-preview-card {
margin-top: 12px;
margin-bottom: 12px;
}
.link-preview-info {
padding: 12px;
}
.link-preview-title {
font-size: 16px;
}
/* Compose media */
.compose-media-grid {
gap: 8px;
margin-top: 12px;
}
.compose-media-item {
max-height: 100px;
}
.compose-media-item img,
.compose-media-item video {
height: 100px;
}
/* Thread line */
.thread-line {
left: 30px;
top: 60px;
}
/* Notification adjustments */
.notification-row {
padding: 16px;
}
/* Admin adjustments */
.admin-row {
padding: 14px;
}
/* Better tap targets for all buttons */
button {
min-height: 44px;
}
/* Improve readability */
.post-time,
.user-card-handle,
.notification-meta {
font-size: 14px;
}
/* Video embeds */
.video-embed-container {
margin-bottom: 12px;
}
/* Scrolling improvements */
.main {
-webkit-overflow-scrolling: touch;
}
/* Safe area insets for notched devices */
.main {
padding-bottom: calc(70px + env(safe-area-inset-bottom)) !important;
}
/* Improve touch feedback */
.post:active {
background: var(--background-tertiary);
}
.user-row:active,
.user-card:active {
background: var(--background-tertiary);
}
/* Better button touch states */
.btn:active {
transform: scale(0.98);
}
/* Compose media button touch target */
.compose-media-button {
width: 44px;
height: 44px;
}
/* Improve menu dropdown positioning on mobile */
.post-menu-dropdown {
right: 0;
left: auto;
}
/* Better modal positioning on mobile */
.modal {
margin: 16px;
max-width: calc(100vw - 32px);
}
/* Identity unlock prompt mobile fixes */
.card {
max-width: calc(100vw - 32px) !important;
}
/* Fix button sizing in modals */
.card .btn {
min-height: 44px !important;
padding: 10px 16px !important;
font-size: 15px !important;
}
.card .btn-primary {
flex: 1 !important;
}
.card .btn-ghost {
flex: 1 !important;
}
/* Improve form spacing */
form {
padding: 16px;
}
/* Better card spacing */
.card {
padding: 16px;
margin: 12px;
}
/* Improve status pills */
.status-pill {
font-size: 10px;
padding: 4px 8px;
}
/* Better admin filters on mobile */
.admin-filters {
flex-wrap: wrap;
}
/* Improve pill buttons */
.pill {
padding: 8px 14px;
font-size: 13px;
}
/* Better explore content spacing */
.explore-content {
padding-bottom: 80px; padding-bottom: 80px;
} }
/* Improve notification row spacing */
.notification-row {
gap: 12px;
}
/* Better avatar in notifications */
.notification-avatar {
width: 44px;
height: 44px;
}
/* Improve install grid on mobile */
.install-grid {
grid-template-columns: 1fr;
}
/* Better admin summary on mobile */
.install-summary {
grid-template-columns: 1fr;
}
/* Improve compose reply target */
.compose-reply-target {
padding: 12px;
margin-bottom: 12px;
}
/* Better link preview on mobile */
.link-preview-card.mini {
max-height: 100px;
}
.link-preview-card.mini .link-preview-image {
width: 100px;
min-width: 100px;
height: 100px;
}
.link-preview-card.mini .link-preview-image img {
height: 100px;
}
/* Improve post menu button */
.post-menu-btn {
min-width: 44px;
min-height: 44px;
}
/* Better video controls on mobile */
video {
max-width: 100%;
}
/* Improve blurred video container */
.blurred-video-main {
max-height: 400px;
}
/* Better emoji sizing on mobile */
.post-content img.emoji,
.compose-input img.emoji {
height: 1.8em;
width: 1.8em;
}
/* Improve post reasons chips */
.post-reason-chip {
font-size: 11px;
padding: 4px 8px;
}
/* Better NSFW toggle */
.compose-nsfw-toggle {
padding: 8px 12px;
font-size: 13px;
}
/* Improve character counter */
.compose-counter {
font-size: 14px;
font-weight: 500;
}
/* Better footer spacing */
.compose-footer-left {
gap: 12px;
flex-wrap: wrap;
}
/* Improve search section */
.search-section {
padding: 16px 12px;
}
.search-section h2 {
font-size: 13px;
margin-bottom: 12px;
}
/* Better swarm post styling */
.swarm-post-card {
padding: 16px;
}
.swarm-post-content {
font-size: 16px;
}
/* Improve post detail view */
.post.detail {
padding: 20px 16px;
}
/* Better thread view on mobile */
.thread-container {
padding: 0;
}
.post.thread-parent {
padding: 12px 16px;
}
/* Improve reply indicator */
.post-reply-to {
font-size: 14px;
margin-bottom: 8px;
}
/* Better loading states */
.explore-loading,
.explore-empty {
padding: 32px 16px;
}
/* Improve empty states */
.admin-empty,
.notifications-empty {
padding: 32px 16px;
}
/* Chat page mobile optimizations */
.chat-container {
flex-direction: column;
}
.chat-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--border);
}
.chat-main {
width: 100%;
height: calc(100vh - 80px);
}
/* Better form inputs on mobile */
textarea {
font-size: 16px;
}
select {
font-size: 16px;
min-height: 44px;
}
/* Improve modal dialogs on mobile */
dialog {
max-width: calc(100vw - 32px);
margin: 16px;
}
/* Better table responsiveness */
table {
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Improve code blocks on mobile */
pre {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Better image handling */
img {
max-width: 100%;
height: auto;
}
/* Improve settings pages */
.settings-section {
padding: 16px 12px;
}
/* Better profile pages */
.profile-header {
padding: 16px;
}
.profile-stats {
flex-wrap: wrap;
gap: 12px;
}
/* Improve bot pages */
.bot-card {
padding: 16px;
}
/* Better moderation pages */
.moderation-card {
padding: 16px;
}
/* Improve notification pages */
.notifications-header {
padding: 16px;
}
/* Better login/register pages */
.auth-container {
padding: 24px 16px;
}
/* Improve install pages */
.install-header h1 {
font-size: 24px;
}
/* Better admin pages */
.admin-header h1 {
font-size: 22px;
}
/* Improve tabs */
.tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Better dropdown menus */
.dropdown-menu {
max-width: calc(100vw - 32px);
}
/* Improve tooltips on mobile */
[title] {
position: relative;
}
/* Better focus states for accessibility */
button:focus-visible,
a:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Improve skeleton loaders */
.skeleton {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Better error states */
.error-message {
padding: 12px;
font-size: 14px;
}
/* Improve success states */
.success-message {
padding: 12px;
font-size: 14px;
}
/* Better warning states */
.warning-message {
padding: 12px;
font-size: 14px;
}
/* Improve info states */
.info-message {
padding: 12px;
font-size: 14px;
}
} }
/* Explore Page */ /* Explore Page */
+7 -1
View File
@@ -38,7 +38,13 @@ export async function generateMetadata(): Promise<Metadata> {
icon: "/api/favicon", icon: "/api/favicon",
}, },
themeColor: "#0a0a0a", themeColor: "#0a0a0a",
viewport: "width=device-width, initial-scale=1, maximum-scale=1", viewport: {
width: "device-width",
initialScale: 1,
maximumScale: 5,
userScalable: true,
viewportFit: "cover",
},
}; };
} }
+3 -3
View File
@@ -200,12 +200,12 @@ export default function Home() {
borderBottom: '1px solid var(--border)', borderBottom: '1px solid var(--border)',
position: 'sticky', position: 'sticky',
top: 0, top: 0,
background: 'var(--background)', background: 'rgba(10, 10, 10, 0.95)',
zIndex: 10, zIndex: 10,
backdropFilter: 'blur(12px)', backdropFilter: 'blur(12px)',
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', flexWrap: 'wrap' }}>
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>Home</h1> <h1 style={{ fontSize: '20px', fontWeight: 600 }}>Home</h1>
<div className="feed-toggle"> <div className="feed-toggle">
<button <button
className={`feed-toggle-btn ${feedType === 'following' ? 'active' : ''}`} className={`feed-toggle-btn ${feedType === 'following' ? 'active' : ''}`}
-211
View File
@@ -1,211 +0,0 @@
/**
* Bot Management Page
*
* Lists user's bots and provides creation interface.
*
* Requirements: 1.3
*/
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { Bot, Plus, Sparkles } from 'lucide-react';
interface BotData {
id: string;
name: string;
handle: string;
bio: string;
avatarUrl: string | null;
isActive: boolean;
isSuspended: boolean;
autonomousMode: boolean;
lastPostAt: Date | null;
createdAt: Date;
}
export default function BotsPage() {
const router = useRouter();
const [bots, setBots] = useState<BotData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchBots();
}, []);
const fetchBots = async () => {
try {
const response = await fetch('/api/bots');
if (response.ok) {
const data = await response.json();
setBots(data.bots || []);
}
} catch (error) {
console.error('Failed to fetch bots:', error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '24px 16px 64px' }}>
<div style={{ textAlign: 'center', padding: '48px 24px', color: 'var(--foreground-tertiary)' }}>
Loading...
</div>
</div>
);
}
return (
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '32px' }}>
<Link href="/settings" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon />
</Link>
<div style={{ flex: 1 }}>
<h1 style={{ fontSize: '24px', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Bot size={24} />
My Bots
</h1>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
Create and manage your automated bots
</p>
</div>
<Link href="/settings/bots/new" className="btn btn-primary">
<Plus size={18} />
Create Bot
</Link>
</header>
{bots.length === 0 ? (
<div className="card" style={{ padding: '48px 24px', textAlign: 'center' }}>
<Bot size={48} style={{ margin: '0 auto 16px', color: 'var(--foreground-tertiary)', opacity: 0.5 }} />
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '8px' }}>
No bots yet
</h2>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px', marginBottom: '24px' }}>
Create your first bot to start automating posts and interactions
</p>
<Link href="/settings/bots/new" className="btn btn-primary">
<Plus size={18} />
Create Your First Bot
</Link>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{bots.map((bot) => (
<div
key={bot.id}
className="card"
style={{
padding: '20px',
cursor: 'pointer',
transition: 'border-color 0.15s ease',
}}
onClick={() => router.push(`/settings/bots/${bot.id}`)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '12px' }}>
<div style={{ display: 'flex', gap: '12px', flex: 1, minWidth: 0 }}>
<Link
href={`/u/${bot.handle}`}
onClick={(e) => e.stopPropagation()}
className="avatar"
style={{
width: '48px',
height: '48px',
flexShrink: 0,
fontSize: '18px',
}}
>
{bot.avatarUrl ? (
<img src={bot.avatarUrl} alt={bot.name} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }} />
) : (
bot.name.charAt(0).toUpperCase()
)}
</Link>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600 }}>{bot.name}</h2>
{bot.autonomousMode && (
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
fontSize: '11px',
padding: '3px 8px',
borderRadius: 'var(--radius-full)',
background: 'var(--accent-muted)',
color: 'var(--accent)',
}}>
<Sparkles size={12} />
Auto
</span>
)}
</div>
<Link
href={`/u/${bot.handle}`}
onClick={(e) => e.stopPropagation()}
style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}
>
@{bot.handle}
</Link>
{bot.bio && (
<p style={{
fontSize: '13px',
color: 'var(--foreground-secondary)',
marginTop: '8px',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}>
{bot.bio}
</p>
)}
</div>
</div>
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
{bot.isSuspended ? (
<span className="status-pill suspended">
Suspended
</span>
) : bot.isActive ? (
<span className="status-pill active">
Active
</span>
) : (
<span className="status-pill">
Inactive
</span>
)}
</div>
</div>
<div style={{
display: 'flex',
gap: '16px',
fontSize: '12px',
color: 'var(--foreground-tertiary)',
paddingTop: '12px',
borderTop: '1px solid var(--border)',
}}>
<span>
Last post: {bot.lastPostAt
? new Date(bot.lastPostAt).toLocaleDateString()
: 'Never'}
</span>
<span>
Created: {new Date(bot.createdAt).toLocaleDateString()}
</span>
</div>
</div>
))}
</div>
)}
</div>
);
}
+32 -30
View File
@@ -1,45 +1,30 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { Rocket, Shield, Bell, Bot, Eye, UserX } from 'lucide-react'; import { Rocket, Shield, Bell, Eye, UserX } from 'lucide-react';
export default function SettingsPage() { export default function SettingsPage() {
return ( return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}> <>
<header style={{ <header style={{
display: 'flex', padding: '16px',
alignItems: 'center', borderBottom: '1px solid var(--border)',
gap: '16px', position: 'sticky',
marginBottom: '32px', top: 0,
background: 'var(--background)',
zIndex: 10,
backdropFilter: 'blur(12px)',
}}> }}>
<Link href="/" style={{ color: 'var(--foreground)' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<ArrowLeftIcon /> <h1 style={{ fontSize: '18px', fontWeight: 600 }}>Settings</h1>
</Link>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Settings</h1>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
Manage your account
</p>
</div> </div>
</header> </header>
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<Link href="/settings/bots" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Bot size={18} />
Bots
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Create and manage automated bots
</div>
</Link>
<Link href="/settings/content" className="card" style={{ <Link href="/settings/content" className="card" style={{
display: 'block', display: 'block',
@@ -57,6 +42,22 @@ export default function SettingsPage() {
</div> </div>
</Link> </Link>
<Link href="/settings/privacy" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Shield size={18} />
Privacy & Safety
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Control who can message you
</div>
</Link>
<Link href="/settings/moderation" className="card" style={{ <Link href="/settings/moderation" className="card" style={{
display: 'block', display: 'block',
padding: '20px', padding: '20px',
@@ -120,5 +121,6 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
</div> </div>
</>
); );
} }
+201
View File
@@ -0,0 +1,201 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeftIcon } from '@/components/Icons';
import { MessageSquare, Check } from 'lucide-react';
export default function PrivacySettingsPage() {
const router = useRouter();
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dmPrivacy, setDmPrivacy] = useState<'everyone' | 'following' | 'none'>('everyone');
const [status, setStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null);
useEffect(() => {
fetch('/api/auth/me')
.then(res => res.json())
.then(data => {
if (data.user) {
setUser(data.user);
setDmPrivacy(data.user.dmPrivacy || 'everyone');
}
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const handleSave = async (newValue: 'everyone' | 'following' | 'none') => {
setDmPrivacy(newValue);
setSaving(true);
setStatus(null);
try {
const res = await fetch('/api/auth/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dmPrivacy: newValue }),
});
if (res.ok) {
setStatus({ type: 'success', message: 'Settings saved successfully' });
setTimeout(() => setStatus(null), 3000);
} else {
const data = await res.json();
setStatus({ type: 'error', message: data.error || 'Failed to save settings' });
}
} catch (error) {
setStatus({ type: 'error', message: 'An error occurred' });
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
Loading privacy settings...
</div>
);
}
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '32px',
}}>
<button
onClick={() => router.back()}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
color: 'var(--foreground)',
display: 'flex',
alignItems: 'center'
}}
>
<ArrowLeftIcon />
</button>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Privacy & Safety</h1>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
Message and social privacy preferences
</p>
</div>
</header>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div style={{ marginBottom: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '16px' }}>
<MessageSquare size={20} style={{ color: 'var(--accent)' }} />
<h2 style={{ fontSize: '18px', fontWeight: 600, margin: 0 }}>Direct Messages</h2>
</div>
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px', marginBottom: '20px' }}>
Control who can send you direct messages on Synapsis.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<button
onClick={() => handleSave('everyone')}
disabled={saving}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px',
background: dmPrivacy === 'everyone' ? 'var(--accent-muted)' : 'var(--background-secondary)',
border: '1px solid',
borderColor: dmPrivacy === 'everyone' ? 'var(--accent)' : 'var(--border)',
borderRadius: '12px',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.15s ease',
width: '100%',
color: 'var(--foreground)',
}}
>
<div>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>Everyone</div>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>Anyone can message you</div>
</div>
{dmPrivacy === 'everyone' && <Check size={18} style={{ color: 'var(--accent)' }} />}
</button>
<button
onClick={() => handleSave('following')}
disabled={saving}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px',
background: dmPrivacy === 'following' ? 'var(--accent-muted)' : 'var(--background-secondary)',
border: '1px solid',
borderColor: dmPrivacy === 'following' ? 'var(--accent)' : 'var(--border)',
borderRadius: '12px',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.15s ease',
width: '100%',
color: 'var(--foreground)',
}}
>
<div>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>Following Only</div>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>Only accounts you follow can message you</div>
</div>
{dmPrivacy === 'following' && <Check size={18} style={{ color: 'var(--accent)' }} />}
</button>
<button
onClick={() => handleSave('none')}
disabled={saving}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px',
background: dmPrivacy === 'none' ? 'var(--accent-muted)' : 'var(--background-secondary)',
border: '1px solid',
borderColor: dmPrivacy === 'none' ? 'var(--accent)' : 'var(--border)',
borderRadius: '12px',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.15s ease',
width: '100%',
color: 'var(--foreground)',
}}
>
<div>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>None</div>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>No one can send you new messages</div>
</div>
{dmPrivacy === 'none' && <Check size={18} style={{ color: 'var(--accent)' }} />}
</button>
</div>
</div>
{status && (
<div style={{
padding: '12px',
borderRadius: '8px',
background: status.type === 'success' ? 'rgba(34, 197, 94, 0.1)' : 'rgba(239, 68, 68, 0.1)',
color: status.type === 'success' ? '#22c55e' : '#ef4444',
fontSize: '14px',
textAlign: 'center',
marginTop: '16px',
}}>
{status.message}
</div>
)}
</div>
</div>
);
}
+2 -2
View File
@@ -504,8 +504,8 @@ export default function ProfilePage() {
{isFollowing ? 'Following' : 'Follow'} {isFollowing ? 'Following' : 'Follow'}
</button> </button>
)} )}
{/* Message Button (V2 Chat) */} {/* Message Button (V2 Chat) - Respect privacy settings */}
{user.did && ( {user.did && !user.isBot && (user as any).canReceiveDms !== false && (
<Link href={`/chat?compose=${user.handle}`} className="btn btn-ghost" style={{ padding: '8px' }}> <Link href={`/chat?compose=${user.handle}`} className="btn btn-ghost" style={{ padding: '8px' }}>
<Mail size={20} /> <Mail size={20} />
</Link> </Link>
+78 -37
View File
@@ -66,46 +66,71 @@ export function IdentityUnlockPrompt({ onUnlock, onCancel }: IdentityUnlockPromp
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
background: 'rgba(0, 0, 0, 0.5)', background: 'rgba(0, 0, 0, 0.8)',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'flex-end',
justifyContent: 'center', justifyContent: 'center',
zIndex: 99999, zIndex: 99999,
padding: '16px' padding: 0
}} }}
onClick={handleCancel} onClick={handleCancel}
> >
<div <div
className="card" className="identity-unlock-sheet"
style={{ style={{
maxWidth: '400px',
width: '100%', width: '100%',
padding: '24px' maxWidth: '100%',
background: 'var(--background-secondary)',
borderTopLeftRadius: '20px',
borderTopRightRadius: '20px',
padding: '24px',
paddingBottom: 'calc(24px + env(safe-area-inset-bottom))',
boxShadow: '0 -4px 20px rgba(0, 0, 0, 0.5)',
animation: 'slideUp 0.3s ease-out'
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Header */} <style jsx>{`
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}> @keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
`}</style>
{/* Drag indicator */}
<div style={{ <div style={{
width: '40px', width: '40px',
height: '40px', height: '4px',
background: 'var(--border)',
borderRadius: '2px',
margin: '0 auto 20px'
}} />
{/* Header */}
<div style={{ textAlign: 'center', marginBottom: '20px' }}>
<div style={{
width: '56px',
height: '56px',
borderRadius: '50%', borderRadius: '50%',
background: 'rgba(59, 130, 246, 0.1)', background: 'rgba(255, 255, 255, 0.1)',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center' justifyContent: 'center',
margin: '0 auto 16px'
}}> }}>
<Lock size={20} style={{ color: 'var(--accent)' }} /> <Lock size={28} style={{ color: 'var(--accent)' }} />
</div> </div>
<h2 style={{ fontSize: '18px', fontWeight: 600, margin: 0 }}> <h2 style={{ fontSize: '22px', fontWeight: 600, margin: '0 0 8px 0' }}>
Unlock Identity Identity Required
</h2> </h2>
</div> <p style={{ color: 'var(--foreground-secondary)', margin: 0, lineHeight: 1.5, fontSize: '15px' }}>
Enter your password to unlock your identity
{/* Description */}
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '20px', lineHeight: 1.5 }}>
Enter your password to unlock your cryptographic identity and perform actions.
</p> </p>
</div>
{/* Form */} {/* Form */}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
@@ -128,19 +153,19 @@ export function IdentityUnlockPrompt({ onUnlock, onCancel }: IdentityUnlockPromp
value={password} value={password}
onChange={(e) => { onChange={(e) => {
setPassword(e.target.value); setPassword(e.target.value);
setError(null); // Clear error when user types setError(null);
}} }}
disabled={isUnlocking} disabled={isUnlocking}
placeholder="Enter your password" placeholder="Enter your password"
autoFocus autoFocus
style={{ style={{
width: '100%', width: '100%',
padding: '10px 12px', padding: '14px 16px',
borderRadius: '8px', borderRadius: '12px',
border: error ? '1px solid var(--error)' : '1px solid var(--border)', border: error ? '2px solid var(--error)' : '2px solid var(--border)',
background: 'var(--background)', background: 'var(--background)',
color: 'var(--foreground)', color: 'var(--foreground)',
fontSize: '14px', fontSize: '16px',
outline: 'none', outline: 'none',
transition: 'border-color 0.2s' transition: 'border-color 0.2s'
}} }}
@@ -163,39 +188,47 @@ export function IdentityUnlockPrompt({ onUnlock, onCancel }: IdentityUnlockPromp
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '8px', gap: '8px',
padding: '10px 12px', padding: '12px 14px',
borderRadius: '8px', borderRadius: '10px',
background: 'rgba(239, 68, 68, 0.1)', background: 'rgba(239, 68, 68, 0.1)',
color: 'var(--error)', color: 'var(--error)',
fontSize: '14px', fontSize: '14px',
marginBottom: '16px' marginBottom: '16px'
}}> }}>
<AlertCircle size={16} /> <AlertCircle size={18} />
<span>{error}</span> <span>{error}</span>
</div> </div>
)} )}
{/* Buttons */} {/* Buttons */}
<div style={{ display: 'flex', gap: '8px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '20px' }}>
<button <button
type="submit" type="submit"
disabled={isUnlocking || !password.trim()} disabled={isUnlocking || !password.trim()}
className="btn btn-primary" className="btn btn-primary"
style={{ style={{
flex: 1, width: '100%',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: '8px' gap: '8px',
minHeight: '52px',
padding: '14px 20px',
fontSize: '16px',
fontWeight: 600,
borderRadius: '12px'
}} }}
> >
{isUnlocking ? ( {isUnlocking ? (
<> <>
<Loader2 size={18} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />
<span>Unlocking...</span> <span>Unlocking...</span>
</> </>
) : ( ) : (
'Unlock' <>
<Lock size={18} />
<span>Unlock Identity</span>
</>
)} )}
</button> </button>
<button <button
@@ -203,7 +236,14 @@ export function IdentityUnlockPrompt({ onUnlock, onCancel }: IdentityUnlockPromp
onClick={handleCancel} onClick={handleCancel}
disabled={isUnlocking} disabled={isUnlocking}
className="btn btn-ghost" className="btn btn-ghost"
style={{ flex: 1 }} style={{
width: '100%',
minHeight: '52px',
padding: '14px 20px',
fontSize: '16px',
fontWeight: 500,
borderRadius: '12px'
}}
> >
Cancel Cancel
</button> </button>
@@ -212,13 +252,14 @@ export function IdentityUnlockPrompt({ onUnlock, onCancel }: IdentityUnlockPromp
{/* Info Note */} {/* Info Note */}
<p style={{ <p style={{
fontSize: '12px', fontSize: '13px',
color: 'var(--foreground-tertiary)', color: 'var(--foreground-tertiary)',
marginTop: '16px', marginTop: '20px',
marginBottom: 0, marginBottom: 0,
lineHeight: 1.4 lineHeight: 1.5,
textAlign: 'center'
}}> }}>
You can browse without unlocking, but you'll need to unlock to like, post, or follow. Your password never leaves this device
</p> </p>
</div> </div>
</div> </div>
+1 -1
View File
@@ -51,7 +51,7 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
} }
return ( return (
<div className="layout"> <div className="layout" style={{ position: 'relative', minHeight: '100vh' }}>
<Sidebar /> <Sidebar />
<main className="main"> <main className="main">
{children} {children}
+23 -17
View File
@@ -95,87 +95,93 @@ export function Sidebar() {
</Link> </Link>
<nav> <nav>
{user && ( {user && (
<Link href="/" className={`nav-item ${isHome ? 'active' : ''}`}> <Link href="/" className={`nav-item ${isHome ? 'active' : ''}`} title="Home">
<HomeIcon /> <HomeIcon />
<span>Home</span> <span>Home</span>
</Link> </Link>
)} )}
<Link href="/explore" className={`nav-item ${pathname?.startsWith('/explore') ? 'active' : ''}`}> <Link href="/explore" className={`nav-item ${pathname?.startsWith('/explore') ? 'active' : ''}`} title="Explore">
<SearchIcon /> <SearchIcon />
<span>Explore</span> <span>Explore</span>
</Link> </Link>
{user && ( {user && (
<Link href="/notifications" className={`nav-item ${pathname?.startsWith('/notifications') ? 'active' : ''}`}> <Link href="/notifications" className={`nav-item ${pathname?.startsWith('/notifications') ? 'active' : ''}`} title="Notifications">
<BellIcon /> <BellIcon />
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> <span style={{ display: 'flex', alignItems: 'center', gap: '4px', position: 'relative' }}>
Notifications <span className="nav-label">Notifications</span>
{unreadCount > 0 && ( {unreadCount > 0 && (
<span style={{ <span style={{
position: 'absolute',
top: '-4px',
right: '-4px',
width: '8px', width: '8px',
height: '8px', height: '8px',
background: 'var(--error)', background: 'var(--error)',
borderRadius: '50%', borderRadius: '50%',
}} /> }} className="notification-dot" />
)} )}
</span> </span>
</Link> </Link>
)} )}
{user && ( {user && (
<Link href="/chat" className={`nav-item ${pathname?.startsWith('/chat') ? 'active' : ''}`}> <Link href="/chat" className={`nav-item ${pathname?.startsWith('/chat') ? 'active' : ''}`} title="Chat">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path> <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg> </svg>
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> <span style={{ display: 'flex', alignItems: 'center', gap: '4px', position: 'relative' }}>
Chat <span className="nav-label">Chat</span>
{unreadChatCount > 0 && ( {unreadChatCount > 0 && (
<span style={{ <span style={{
position: 'absolute',
top: '-4px',
right: '-4px',
width: '8px', width: '8px',
height: '8px', height: '8px',
background: 'var(--error)', background: 'var(--error)',
borderRadius: '50%', borderRadius: '50%',
}} /> }} className="notification-dot" />
)} )}
</span> </span>
</Link> </Link>
)} )}
{user && ( {user && (
<Link href="/settings/bots" className={`nav-item ${pathname?.startsWith('/settings/bots') ? 'active' : ''}`}> <Link href="/bots" className={`nav-item ${pathname?.startsWith('/bots') ? 'active' : ''}`} title="Bots">
<BotIcon /> <BotIcon />
<span>Bots</span> <span>Bots</span>
</Link> </Link>
)} )}
{user ? ( {user ? (
<Link href={`/u/${user.handle}`} className={`nav-item ${pathname === '/u/' + user.handle ? 'active' : ''}`}> <Link href={`/u/${user.handle}`} className={`nav-item ${pathname === '/u/' + user.handle ? 'active' : ''}`} title="Profile">
<UserIcon /> <UserIcon />
<span>Profile</span> <span>Profile</span>
</Link> </Link>
) : ( ) : (
<Link href="/login" className={`nav-item ${pathname === '/login' ? 'active' : ''}`}> <Link href="/login" className={`nav-item ${pathname === '/login' ? 'active' : ''}`} title="Login">
<UserIcon /> <UserIcon />
<span>Login</span> <span>Login</span>
</Link> </Link>
)} )}
{isAdmin && ( {isAdmin && (
<Link href="/moderation" className={`nav-item ${pathname?.startsWith('/moderation') ? 'active' : ''}`}> <Link href="/moderation" className={`nav-item ${pathname?.startsWith('/moderation') ? 'active' : ''}`} title="Moderation">
<ShieldIcon /> <ShieldIcon />
<span>Moderation</span> <span>Moderation</span>
</Link> </Link>
)} )}
{isAdmin && ( {isAdmin && (
<Link href="/admin" className={`nav-item ${pathname?.startsWith('/admin') ? 'active' : ''}`}> <Link href="/admin" className={`nav-item ${pathname?.startsWith('/admin') ? 'active' : ''}`} title="Admin">
<Settings2 size={24} /> <Settings2 size={24} />
<span>Admin</span> <span>Admin</span>
</Link> </Link>
)} )}
{user && ( {user && (
<Link href="/settings" className={`nav-item ${pathname?.startsWith('/settings') ? 'active' : ''}`}> <Link href="/settings" className={`nav-item ${pathname?.startsWith('/settings') ? 'active' : ''}`} title="Settings">
<SettingsIcon /> <SettingsIcon />
<span>Settings</span> <span>Settings</span>
</Link> </Link>
)} )}
</nav> </nav>
{user && ( {user && (
<div style={{ marginTop: 'auto', paddingTop: '16px' }}> <div style={{ marginTop: 'auto', paddingTop: '16px' }} className="sidebar-user-info">
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', minWidth: 0, marginBottom: '12px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '12px', minWidth: 0, marginBottom: '12px' }}>
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}> <div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
{user.avatarUrl ? ( {user.avatarUrl ? (
+3 -110
View File
@@ -43,9 +43,6 @@ export const users = pgTable('users', {
headerUrl: text('header_url'), headerUrl: text('header_url'),
privateKeyEncrypted: text('private_key_encrypted'), // For cryptographic signing privateKeyEncrypted: text('private_key_encrypted'), // For cryptographic signing
publicKey: text('public_key').notNull(), publicKey: text('public_key').notNull(),
// E2E Chat keys (ECDH) - separate from signing keys
chatPublicKey: text('chat_public_key'), // ECDH public key for chat encryption
chatPrivateKeyEncrypted: text('chat_private_key_encrypted'), // Encrypted with user's password
nodeId: uuid('node_id').references(() => nodes.id), nodeId: uuid('node_id').references(() => nodes.id),
// Bot-related fields // Bot-related fields
isBot: boolean('is_bot').default(false).notNull(), isBot: boolean('is_bot').default(false).notNull(),
@@ -69,6 +66,7 @@ export const users = pgTable('users', {
followingCount: integer('following_count').default(0).notNull(), followingCount: integer('following_count').default(0).notNull(),
postsCount: integer('posts_count').default(0).notNull(), postsCount: integer('posts_count').default(0).notNull(),
website: text('website'), website: text('website'),
dmPrivacy: text('dm_privacy').default('everyone').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(), createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => [ }, (table) => [
@@ -927,12 +925,8 @@ export const chatMessages = pgTable('chat_messages', {
senderNodeDomain: text('sender_node_domain'), // null if local senderNodeDomain: text('sender_node_domain'), // null if local
senderDid: text('sender_did'), // DID for Signal Protocol senderDid: text('sender_did'), // DID for Signal Protocol
// Message content (encrypted for recipient with their public key) // Message content (plain text for verified chat)
encryptedContent: text('encrypted_content').notNull(), content: text('content'),
// Sender's copy (encrypted with sender's public key so they can read their own messages)
senderEncryptedContent: text('sender_encrypted_content'),
// Sender's ECDH public key (for E2E decryption by recipient)
senderChatPublicKey: text('sender_chat_public_key'),
// Swarm sync info // Swarm sync info
swarmMessageId: text('swarm_message_id').unique(), // Format: swarm:domain:uuid swarmMessageId: text('swarm_message_id').unique(), // Format: swarm:domain:uuid
@@ -956,108 +950,7 @@ export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({
}), }),
})); }));
/**
* V2.1 E2EE: Device Bundles
* Stores the identity and signed prekeys for each user device.
* Verified by the root ECDSA identity key (signature).
*/
export const chatDeviceBundles = pgTable('chat_device_bundles', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
did: text('did').notNull(),
// The device identifier (UUID generated by client)
deviceId: text('device_id').notNull(),
// Signal Protocol fields
registrationId: integer('registration_id'),
// X25519 Identity Key (Base64)
identityKey: text('identity_key').notNull(),
// Signed PreKey (JSON: { id, key, sig })
signedPreKey: text('signed_pre_key').notNull(),
// Kyber PreKey for post-quantum security (JSON: { id, key, sig })
kyberPreKey: text('kyber_pre_key'),
// One-Time Keys (JSON array of { id, key }) - cached/uploaded batch
// Note: Individual keys are usually stored in a separate table for atomic consumption,
// but initial upload can be here or we strictly use chat_one_time_keys.
// We will use the separate table for consumption tracking.
// The ECDSA signature covering (did, deviceId, identityKey, signedPreKey)
// Signed by the user's Root Identity Key (P-256)
signature: text('signature').notNull(),
lastSeenAt: timestamp('last_seen_at').defaultNow().notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => [
index('chat_bundles_user_idx').on(table.userId),
index('chat_bundles_did_idx').on(table.did),
// compound index for fast lookup of a specific device
uniqueIndex('chat_bundles_device_unique').on(table.userId, table.deviceId),
]);
export const chatDeviceBundlesRelations = relations(chatDeviceBundles, ({ one, many }) => ({
user: one(users, {
fields: [chatDeviceBundles.userId],
references: [users.id],
}),
oneTimeKeys: many(chatOneTimeKeys),
}));
/**
* V2.1 E2EE: One-Time Prekeys
* Pool of disposable keys for X3DH.
*/
export const chatOneTimeKeys = pgTable('chat_one_time_keys', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
bundleId: uuid('bundle_id').notNull().references(() => chatDeviceBundles.id, { onDelete: 'cascade' }),
keyId: integer('key_id').notNull(),
publicKey: text('public_key').notNull(), // X25519 Base64
createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => [
index('chat_otk_bundle_idx').on(table.bundleId),
// Ensure (bundleId, keyId) is unique
uniqueIndex('chat_otk_unique').on(table.bundleId, table.keyId),
]);
export const chatOneTimeKeysRelations = relations(chatOneTimeKeys, ({ one }) => ({
bundle: one(chatDeviceBundles, {
fields: [chatOneTimeKeys.bundleId],
references: [chatDeviceBundles.id],
}),
}));
/**
* V2.1 E2EE: Chat Inbox
* Stores encrypted envelopes waiting for delivery to a specific device.
*/
export const chatInbox = pgTable('chat_inbox', {
id: uuid('id').primaryKey().defaultRandom(),
// Recipient info
recipientDid: text('recipient_did').notNull(),
recipientDeviceId: text('recipient_device_id'), // Null means "all devices" or "not yet routed"
// Sender info
senderDid: text('sender_did').notNull(),
// The Envelope (SignedAction format)
// Contains: { action: "chat.deliver", did, handle, ts, nonce, data: { ciphertext, header... }, sig }
envelope: text('envelope_json').notNull(),
isRead: boolean('is_read').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
expiresAt: timestamp('expires_at').notNull(), // TTL (e.g. 30 days)
}, (table) => [
index('chat_inbox_recipient_idx').on(table.recipientDid, table.recipientDeviceId),
index('chat_inbox_created_idx').on(table.createdAt),
]);
/** /**
* Typing indicators for real-time chat UX. * Typing indicators for real-time chat UX.
+12
View File
@@ -210,4 +210,16 @@ export const signedAPI = {
userHandle userHandle
); );
}, },
/**
* Send a chat message
*/
async sendChat(recipientDid: string, recipientHandle: string, content: string, userDid: string, userHandle: string) {
return signedFetch(
'/api/chat/send',
'chat',
{ recipientDid, recipientHandle, content },
userDid,
userHandle
);
},
}; };
+36 -31
View File
@@ -29,7 +29,39 @@ export interface SignedAction {
} }
/** /**
* Verify a signed user action * Verify a signed action against a specific public key
*/
export async function verifyActionSignature(signedAction: SignedAction, publicKeyStr: string): Promise<boolean> {
try {
const { sig, ...payload } = signedAction;
const canonicalString = canonicalize(payload);
const encoder = new TextEncoder();
const dataBytes = encoder.encode(canonicalString);
// Convert signature from Base64Url to buffer
const sigBase64 = base64UrlToBase64(sig);
const sigBuffer = Buffer.from(sigBase64, 'base64');
// Import public key (stored as SPKI Base64)
const publicKey = await importPublicKey(publicKeyStr);
return await cryptoSubtle.verify(
{
name: 'ECDSA',
hash: { name: 'SHA-256' },
},
publicKey,
sigBuffer,
dataBytes
);
} catch (error) {
console.error('[Verify] Crypto exception:', error);
return false;
}
}
/**
* Verify a signed user action (looks up user in DB)
* *
* @param signedAction - The signed action payload * @param signedAction - The signed action payload
* @returns The user if signature is valid and not replayed * @returns The user if signature is valid and not replayed
@@ -48,6 +80,7 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
// 1. FRESHNESS CHECK (Fail fast before DB/Crypto) // 1. FRESHNESS CHECK (Fail fast before DB/Crypto)
const now = Date.now(); const now = Date.now();
const diff = Math.abs(now - payload.ts); const diff = Math.abs(now - payload.ts);
// Allow 5 minutes clock skew
const fiveMinutesMs = 5 * 60 * 1000; const fiveMinutesMs = 5 * 60 * 1000;
if (diff > fiveMinutesMs) { if (diff > fiveMinutesMs) {
@@ -60,8 +93,6 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
}); });
if (!user) { if (!user) {
// If federation, we might need to look up in remote_identity_cache here.
// For now, assume local user or user must exist in users table (synced).
return { valid: false, error: 'User not found' }; return { valid: false, error: 'User not found' };
} }
@@ -70,35 +101,14 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
} }
// 3. CRYPTOGRAPHIC VERIFICATION // 3. CRYPTOGRAPHIC VERIFICATION
try { const isValid = await verifyActionSignature(signedAction, user.publicKey);
const canonicalString = canonicalize(payload);
const encoder = new TextEncoder();
const dataBytes = encoder.encode(canonicalString);
// Convert signature from Base64Url to buffer
const sigBase64 = base64UrlToBase64(sig);
const sigBuffer = Buffer.from(sigBase64, 'base64');
// Import public key (stored as SPKI Base64 in DB)
const publicKey = await importPublicKey(user.publicKey);
const isValid = await cryptoSubtle.verify(
{
name: 'ECDSA',
hash: { name: 'SHA-256' },
},
publicKey,
sigBuffer,
dataBytes
);
if (!isValid) { if (!isValid) {
return { valid: false, error: 'INVALID_SIGNATURE' }; return { valid: false, error: 'INVALID_SIGNATURE' };
} }
// 4. ACTION ID HASH COMPUTATION // 4. ACTION ID HASH COMPUTATION
// SHA-256(canonicalPayload) const canonicalString = canonicalize(payload);
// We use the same canonical string we just verified.
const actionIdHash = crypto.createHash('sha256').update(canonicalString).digest('hex'); const actionIdHash = crypto.createHash('sha256').update(canonicalString).digest('hex');
// 5. REPLAY PROTECTION (DB) // 5. REPLAY PROTECTION (DB)
@@ -119,11 +129,6 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
} }
return { valid: true, user }; return { valid: true, user };
} catch (error) {
console.error('[Verify] Verification exception:', error);
return { valid: false, error: 'VERIFICATION_ERROR' };
}
} }
/** /**
-1
View File
@@ -87,7 +87,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
targetUser.publicKey targetUser.publicKey
); );
// Signal Protocol will auto-initialize when the chat page is opened
setShowUnlockPrompt(false); // Close prompt on success setShowUnlockPrompt(false); // Close prompt on success
}; };
-218
View File
@@ -1,218 +0,0 @@
/**
* Client-Side E2E Encryption using Web Crypto API
*
* This runs in the browser. Private keys NEVER leave the client.
* Uses ECDH for key exchange and AES-GCM for encryption.
*/
// Storage keys
const PRIVATE_KEY_STORAGE = 'synapsis_chat_private_key';
const PUBLIC_KEY_STORAGE = 'synapsis_chat_public_key';
/**
* Generate a new ECDH key pair for chat
*/
export async function generateKeyPair(): Promise<{ publicKey: string; privateKey: string }> {
const keyPair = await window.crypto.subtle.generateKey(
{
name: 'ECDH',
namedCurve: 'P-256',
},
true, // extractable
['deriveKey']
);
const publicKeyBuffer = await window.crypto.subtle.exportKey('spki', keyPair.publicKey);
const privateKeyBuffer = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
return {
publicKey: bufferToBase64(publicKeyBuffer),
privateKey: bufferToBase64(privateKeyBuffer),
};
}
/**
* Store keys in localStorage (encrypted with a passphrase in production)
*/
export function storeKeys(publicKey: string, privateKey: string): void {
localStorage.setItem(PUBLIC_KEY_STORAGE, publicKey);
localStorage.setItem(PRIVATE_KEY_STORAGE, privateKey);
}
/**
* Get stored keys
*/
export function getStoredKeys(): { publicKey: string | null; privateKey: string | null } {
return {
publicKey: localStorage.getItem(PUBLIC_KEY_STORAGE),
privateKey: localStorage.getItem(PRIVATE_KEY_STORAGE),
};
}
/**
* Check if chat keys exist
*/
export function hasChatKeys(): boolean {
const keys = getStoredKeys();
return !!(keys.publicKey && keys.privateKey);
}
/**
* Clear stored keys (logout)
*/
export function clearKeys(): void {
localStorage.removeItem(PUBLIC_KEY_STORAGE);
localStorage.removeItem(PRIVATE_KEY_STORAGE);
}
/**
* Import a public key from base64
*/
async function importPublicKey(publicKeyBase64: string): Promise<CryptoKey> {
const keyBuffer = base64ToBuffer(publicKeyBase64);
return window.crypto.subtle.importKey(
'spki',
keyBuffer,
{ name: 'ECDH', namedCurve: 'P-256' },
false,
[]
);
}
/**
* Import a private key from base64
*/
async function importPrivateKey(privateKeyBase64: string): Promise<CryptoKey> {
const keyBuffer = base64ToBuffer(privateKeyBase64);
return window.crypto.subtle.importKey(
'pkcs8',
keyBuffer,
{ name: 'ECDH', namedCurve: 'P-256' },
false,
['deriveKey']
);
}
/**
* Derive a shared AES key from ECDH
*/
async function deriveSharedKey(
myPrivateKey: CryptoKey,
theirPublicKey: CryptoKey
): Promise<CryptoKey> {
return window.crypto.subtle.deriveKey(
{ name: 'ECDH', public: theirPublicKey },
myPrivateKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
/**
* Encrypt a message for a recipient
*/
export async function encryptMessage(
message: string,
myPrivateKeyBase64: string,
theirPublicKeyBase64: string
): Promise<string> {
const myPrivateKey = await importPrivateKey(myPrivateKeyBase64);
const theirPublicKey = await importPublicKey(theirPublicKeyBase64);
const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey);
const encoder = new TextEncoder();
const messageBytes = encoder.encode(message);
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
sharedKey,
messageBytes
);
// Combine iv + ciphertext
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(ciphertext), iv.length);
return bufferToBase64(combined.buffer);
}
/**
* Decrypt a message from a sender
*/
export async function decryptMessage(
encryptedMessage: string,
myPrivateKeyBase64: string,
theirPublicKeyBase64: string
): Promise<string> {
try {
const myPrivateKey = await importPrivateKey(myPrivateKeyBase64);
const theirPublicKey = await importPublicKey(theirPublicKeyBase64);
const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey);
const combined = base64ToBuffer(encryptedMessage);
if (combined.byteLength < 12) {
throw new Error('Message too short');
}
const iv = combined.slice(0, 12);
const ciphertext = combined.slice(12);
const decrypted = await window.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
sharedKey,
ciphertext
);
const decoder = new TextDecoder();
return decoder.decode(decrypted);
} catch (error) {
console.error('Decryption failed:', error);
return '[Message cannot be decrypted]';
}
}
// Utility functions
function bufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function base64ToBuffer(base64: string): ArrayBuffer {
// Gracefull handle null/undefined
if (!base64) return new ArrayBuffer(0);
// Check for JSON (legacy format)
if (base64.trim().startsWith('{')) {
console.warn('[base64ToBuffer] Detected JSON instead of Base64, returning empty buffer');
throw new Error('Invalid message format: JSON detected');
}
// Clean the string:
// 1. Remove newlines/tabs (formatting)
// 2. Replace spaces with '+' (common URL decoding error where + becomes space)
// 3. Handle URL-safe chars (- -> +, _ -> /)
const cleaned = base64.replace(/[\n\r\t]/g, '')
.replace(/ /g, '+')
.replace(/-/g, '+')
.replace(/_/g, '/');
try {
const binary = atob(cleaned);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
} catch (e) {
console.error('[base64ToBuffer] Failed to decode base64:', e);
throw new Error(`Failed to decode base64: ${e instanceof Error ? e.message : String(e)}`);
}
}
-302
View File
@@ -1,302 +0,0 @@
/**
* Libsodium E2EE Chat Implementation
* Keys stored encrypted in IndexedDB using storage key from identity unlock
*/
import sodium from 'libsodium-wrappers-sumo';
let sodiumReady = false;
const DB_NAME = 'synapsis_chat';
const DB_VERSION = 1;
const STORE_NAME = 'chat_keys';
/**
* Initialize libsodium (must be called before any crypto operations)
*/
export async function initSodium() {
if (sodiumReady) return;
await sodium.ready;
sodiumReady = true;
}
/**
* Open IndexedDB
*/
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
});
}
/**
* Generate a new key pair for chat encryption
*/
export async function generateChatKeyPair(): Promise<{
publicKey: string; // base64
privateKey: string; // base64
}> {
await initSodium();
const keyPair = sodium.crypto_box_keypair();
return {
publicKey: sodium.to_base64(keyPair.publicKey),
privateKey: sodium.to_base64(keyPair.privateKey),
};
}
// Helper to robustly decode Base64 regardless of variant (Original vs URLSafe)
function tryDecodeBase64(str: string): Uint8Array {
// Use a Set to ensure uniqueness and order, explicitly allowing undefined
const variants = new Set<number | undefined>();
// Prefer standard/known variants first
if (sodium.base64_variants) {
if (sodium.base64_variants.ORIGINAL !== undefined) variants.add(sodium.base64_variants.ORIGINAL);
if (sodium.base64_variants.URLSAFE !== undefined) variants.add(sodium.base64_variants.URLSAFE);
if (sodium.base64_variants.ORIGINAL_NO_PADDING !== undefined) variants.add(sodium.base64_variants.ORIGINAL_NO_PADDING);
if (sodium.base64_variants.URLSAFE_NO_PADDING !== undefined) variants.add(sodium.base64_variants.URLSAFE_NO_PADDING);
}
// Always add default (undefined) as fallback
variants.add(undefined);
let lastError;
for (const v of variants) {
try {
return v !== undefined
? sodium.from_base64(str, v)
: sodium.from_base64(str);
} catch (e) {
lastError = e;
}
}
throw lastError || new Error('Failed to decode Base64 with any variant');
}
/**
* Encrypt a message for a recipient
*/
export async function encryptMessage(
message: string,
recipientPublicKey: string, // base64
senderPrivateKey: string // base64
): Promise<{
ciphertext: string; // base64
nonce: string; // base64
}> {
await initSodium();
try {
const messageBytes = sodium.from_string(message);
// keys may be dirty or differ in variant
const cleanRecipientKey = recipientPublicKey.trim();
const cleanSenderKey = senderPrivateKey.trim();
// Robust decode for both keys independently
// This solves the issue where Local Key is URLSAFE but Remote Key is ORIGINAL
const recipientPubKey = tryDecodeBase64(cleanRecipientKey);
const senderPrivKey = tryDecodeBase64(cleanSenderKey);
// Generate random nonce
const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
// Encrypt
const ciphertext = sodium.crypto_box_easy(
messageBytes,
nonce,
recipientPubKey,
senderPrivKey
);
return {
ciphertext: sodium.to_base64(ciphertext),
nonce: sodium.to_base64(nonce),
};
} catch (err) {
console.error('[Sodium-Chat] Encryption failed:', err);
console.error('Keys Debug:', {
recipientLen: recipientPublicKey.length,
senderLen: senderPrivateKey.length,
recipientStart: recipientPublicKey.substring(0, 5)
});
throw err;
}
}
/**
* Decrypt a message from a sender
*/
export async function decryptMessage(
ciphertext: string, // base64
nonce: string, // base64
senderPublicKey: string, // base64
recipientPrivateKey: string // base64
): Promise<string> {
await initSodium();
const ciphertextBytes = tryDecodeBase64(ciphertext);
const nonceBytes = tryDecodeBase64(nonce);
const senderPubKey = tryDecodeBase64(senderPublicKey);
const recipientPrivKey = tryDecodeBase64(recipientPrivateKey);
// Decrypt
const decrypted = sodium.crypto_box_open_easy(
ciphertextBytes,
nonceBytes,
senderPubKey,
recipientPrivKey
);
return sodium.to_string(decrypted);
}
/**
* Store keys in IndexedDB (encrypted with storage key from memory)
*/
export async function storeKeys(
userId: string,
publicKey: string,
privateKey: string,
storageKey: Uint8Array
): Promise<void> {
await initSodium();
// Generate random nonce
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
// Encrypt private key with storage key
const privateKeyBytes = sodium.from_string(privateKey);
const ciphertext = sodium.crypto_secretbox_easy(privateKeyBytes, nonce, storageKey);
// Combine nonce + ciphertext
const combined = new Uint8Array(nonce.length + ciphertext.length);
combined.set(nonce, 0);
combined.set(ciphertext, nonce.length);
// Store in IndexedDB
const db = await openDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
await new Promise<void>((resolve, reject) => {
const request = store.put({
publicKey,
encryptedPrivateKey: sodium.to_base64(combined)
}, userId);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
db.close();
}
/**
* Retrieve keys from IndexedDB (decrypt with storage key from memory)
*/
export async function getStoredKeys(
userId: string,
storageKey: Uint8Array
): Promise<{ publicKey: string; privateKey: string } | null> {
await initSodium();
try {
const db = await openDB();
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const data = await new Promise<any>((resolve, reject) => {
const request = store.get(userId);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
db.close();
if (!data) {
console.log('[Sodium] No stored keys found in IndexedDB for user:', userId);
return null;
}
console.log('[Sodium] Found stored keys in IndexedDB, attempting to decrypt...');
const { publicKey, encryptedPrivateKey } = data;
// Extract nonce and ciphertext
const combined = sodium.from_base64(encryptedPrivateKey);
const nonce = combined.slice(0, sodium.crypto_secretbox_NONCEBYTES);
const ciphertext = combined.slice(sodium.crypto_secretbox_NONCEBYTES);
// Decrypt with storage key
const decrypted = sodium.crypto_secretbox_open_easy(ciphertext, nonce, storageKey);
let privateKey = sodium.to_string(decrypted);
// Validate that the decrypted key is actually valid Base64
try {
tryDecodeBase64(privateKey);
} catch (e) {
console.warn('[Sodium] Private key appears invalid, attempting repair...');
// Attempt 1: Trim whitespace
let repaired = privateKey.trim();
// Attempt 2: Remove quotes (common JSON artifact)
repaired = repaired.replace(/['"]/g, '');
// Attempt 3: Remove newlines
repaired = repaired.replace(/[\n\r]/g, '');
try {
tryDecodeBase64(repaired);
console.log('[Sodium] Private key REPAIRED successfully!');
privateKey = repaired;
} catch (finalErr) {
console.error('[Sodium] Decrypted private key is IRREPARABLE! Key store corrupted.');
// We have to return null here as last resort, but we tried everything.
// Log the length/characteristics to help debug if this happens
console.error('Bad Key CharCodes:', privateKey.split('').map(c => c.charCodeAt(0)).slice(0, 10));
return null;
}
}
console.log('[Sodium] Successfully decrypted stored keys');
return { publicKey, privateKey };
} catch (error) {
console.error('[Sodium] Failed to decrypt stored keys - storage key mismatch?', error);
return null;
}
}
/**
* Clear stored keys from IndexedDB
*/
export async function clearStoredKeys(userId: string): Promise<void> {
try {
const db = await openDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
await new Promise<void>((resolve, reject) => {
const request = store.delete(userId);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
db.close();
} catch (error) {
console.error('[Sodium] Failed to clear keys:', error);
}
}
+1 -8
View File
@@ -27,7 +27,7 @@ class InMemoryKeyStore implements KeyStore {
private static instance: InMemoryKeyStore; private static instance: InMemoryKeyStore;
private privateKey: CryptoKey | null = null; private privateKey: CryptoKey | null = null;
private identity: { did: string; handle: string; publicKey: string } | null = null; private identity: { did: string; handle: string; publicKey: string } | null = null;
private storageKey: Uint8Array | null = null; // For encrypting chat keys
private constructor() { } private constructor() { }
@@ -57,18 +57,11 @@ class InMemoryKeyStore implements KeyStore {
return this.identity; return this.identity;
} }
setStorageKey(key: Uint8Array): void {
this.storageKey = key;
}
getStorageKey(): Uint8Array | null {
return this.storageKey;
}
clear(): void { clear(): void {
this.privateKey = null; this.privateKey = null;
this.identity = null; this.identity = null;
this.storageKey = null;
} }
} }
-207
View File
@@ -1,207 +0,0 @@
/**
* React Hook for Libsodium E2EE Chat
*/
'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
import { useAuth } from '@/lib/contexts/AuthContext';
import { keyStore } from '@/lib/crypto/user-signing';
import * as SodiumChat from '@/lib/crypto/sodium-chat';
export function useSodiumChat() {
const { user, isIdentityUnlocked } = useAuth();
const [isReady, setIsReady] = useState(false);
const [status, setStatus] = useState<string>('idle');
const keysRef = useRef<{ publicKey: string; privateKey: string } | null>(null);
// Initialize and load/generate keys
useEffect(() => {
if (!user?.id || !isIdentityUnlocked) return;
const init = async () => {
try {
setStatus('initializing');
await SodiumChat.initSodium();
// Get storage key from memory
const storageKey = keyStore.getStorageKey();
if (!storageKey) {
throw new Error('Storage key not available - identity must be unlocked first');
}
// Try to load existing keys (encrypted in IndexedDB)
let keys = await SodiumChat.getStoredKeys(user.id, storageKey);
if (!keys) {
// Generate new keys
console.log('[Sodium] Generating new key pair...');
keys = await SodiumChat.generateChatKeyPair();
await SodiumChat.storeKeys(user.id, keys.publicKey, keys.privateKey, storageKey);
// Publish public key to server
console.log('[Sodium] Publishing public key to server...');
const response = await fetch('/api/chat/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ publicKey: keys.publicKey }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: response.statusText }));
console.error('[Sodium] Failed to publish key:', errorData);
throw new Error(`Failed to publish key: ${errorData.error || response.statusText}`);
}
const result = await response.json();
console.log('[Sodium] Keys generated and published successfully:', result);
} else {
console.log('[Sodium] Loaded existing keys from IndexedDB');
// Verify key exists on server
console.log('[Sodium] Verifying key on server...');
if (!user.did) {
throw new Error('User DID not available');
}
const checkResponse = await fetch(`/api/chat/keys?did=${encodeURIComponent(user.did)}`, { cache: 'no-store' });
let shouldPublish = false;
if (!checkResponse.ok) {
console.log('[Sodium] Key not found on server, re-publishing...');
shouldPublish = true;
} else {
// Check if the key on server MATCHES our local key
const serverData = await checkResponse.json();
if (serverData.publicKey !== keys.publicKey) {
console.warn('[Sodium] Server key mismatch! Re-publishing local key...');
shouldPublish = true;
} else {
console.log('[Sodium] Key verified on server');
}
}
if (shouldPublish) {
// Re-publish the key
const response = await fetch('/api/chat/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ publicKey: keys.publicKey }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: response.statusText }));
console.error('[Sodium] Failed to re-publish key:', errorData);
throw new Error(`Failed to re-publish key: ${errorData.error || response.statusText}`);
}
console.log('[Sodium] Key re-published successfully');
}
}
keysRef.current = keys;
setIsReady(true);
setStatus('ready');
} catch (error) {
console.error('[Sodium] Initialization failed:', error);
setStatus('error');
}
};
init();
}, [user?.id, user?.did, isIdentityUnlocked]);
const sendMessage = useCallback(async (
recipientDid: string,
message: string,
recipientHandle?: string
): Promise<void> => {
if (!keysRef.current || !isReady || !user?.id) {
throw new Error('Sodium not ready');
}
try {
// Fetch recipient's public key
console.log('[Sodium] Fetching recipient public key for:', recipientDid);
let response = await fetch(`/api/chat/keys?did=${encodeURIComponent(recipientDid)}`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: response.statusText }));
console.error('[Sodium] Failed to fetch recipient keys:', errorData);
throw new Error(`Failed to fetch recipient keys: ${errorData.error || response.statusText}`);
}
const { publicKey: recipientPublicKey } = await response.json();
console.log('[Sodium] Got recipient public key:', recipientPublicKey ? 'YES' : 'NO');
if (!recipientPublicKey) {
throw new Error('Recipient has no public key');
}
// Encrypt message
console.log('[Sodium] Encrypting message...');
const encrypted = await SodiumChat.encryptMessage(
message,
recipientPublicKey,
keysRef.current.privateKey
);
// Send to server
console.log('[Sodium] Sending encrypted message to server...');
response = await fetch('/api/chat/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recipientDid,
senderPublicKey: keysRef.current.publicKey,
ciphertext: encrypted.ciphertext,
nonce: encrypted.nonce,
recipientHandle,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: response.statusText }));
console.error('[Sodium] Failed to send message:', errorData);
throw new Error(`Failed to send message: ${errorData.error || response.statusText}`);
}
console.log('[Sodium] Message sent successfully');
} catch (error) {
console.error('[Sodium] Send failed:', error);
throw error;
}
}, [isReady, user?.id]);
const decryptMessage = useCallback(async (
ciphertext: string,
nonce: string,
senderPublicKey: string
): Promise<string> => {
if (!keysRef.current || !isReady) {
throw new Error('Sodium not ready');
}
try {
const plaintext = await SodiumChat.decryptMessage(
ciphertext,
nonce,
senderPublicKey,
keysRef.current.privateKey
);
return plaintext;
} catch (error) {
console.error('[Sodium] Decryption failed:', error);
throw error;
}
}, [isReady]);
return {
isReady,
status,
sendMessage,
decryptMessage,
};
}
+3 -34
View File
@@ -130,43 +130,12 @@ export function useUserIdentity() {
keyStore.setPrivateKey(cryptoKey); keyStore.setPrivateKey(cryptoKey);
console.log('[Identity] Private key stored in memory'); console.log('[Identity] Private key stored in memory');
// 4. Derive and store storage key for chat encryption // 4. Update State
// Use libsodium's pwhash to derive a storage key from the password
const sodiumModule = await import('libsodium-wrappers-sumo');
await sodiumModule.default.ready;
const sodium = sodiumModule.default;
// Use a fixed salt derived from the user's identity to ensure consistency
const identity = keyStore.getIdentity();
console.log('[Identity] Retrieved identity from keyStore:', identity);
if (identity) {
const saltString = `synapsis-chat-storage-${identity.did}`;
// Generate a fixed-length salt from the DID
// Hash to 32 bytes, then take first 16 bytes for salt
const fullHash = sodium.crypto_generichash(32, saltString, null);
const salt = fullHash.slice(0, sodium.crypto_pwhash_SALTBYTES);
console.log('[Identity] Deriving storage key...');
const storageKey = sodium.crypto_pwhash(
32, // 32 bytes for secretbox
password,
salt,
sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
sodium.crypto_pwhash_ALG_DEFAULT
);
keyStore.setStorageKey(storageKey);
console.log('[Identity] Storage key derived and stored');
} else {
console.error('[Identity] No identity in keyStore - cannot derive storage key');
}
// 5. Update State
setIdentity(prev => prev ? { ...prev, isUnlocked: true } : null); // We need the other data... setIdentity(prev => prev ? { ...prev, isUnlocked: true } : null); // We need the other data...
setIsUnlocked(true); setIsUnlocked(true);
// If we didn't have identity wrapper set yet, we might need it. // If we didn't have identity wrapper set yet, we might need it.
// Usually initializeIdentity handles both. // Usually initializeIdentity handles both.
-131
View File
@@ -1,131 +0,0 @@
/**
* Swarm Chat Cryptography
*
* End-to-end encryption for chat messages using hybrid encryption:
* - AES-256-GCM for message encryption (fast, no size limit)
* - RSA-OAEP for encrypting the AES key (secure key exchange)
*/
import crypto from 'crypto';
interface EncryptedPayload {
encryptedKey: string; // RSA-encrypted AES key (base64)
iv: string; // AES initialization vector (base64)
ciphertext: string; // AES-encrypted message (base64)
authTag: string; // GCM authentication tag (base64)
}
/**
* Encrypt a message using hybrid encryption (AES + RSA)
*/
export function encryptMessage(message: string, recipientPublicKey: string): string {
try {
// Generate a random AES-256 key
const aesKey = crypto.randomBytes(32);
// Generate a random IV for AES-GCM
const iv = crypto.randomBytes(12);
// Encrypt the message with AES-256-GCM
const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv);
const encrypted = Buffer.concat([
cipher.update(message, 'utf8'),
cipher.final()
]);
const authTag = cipher.getAuthTag();
// Encrypt the AES key with RSA-OAEP
const encryptedKey = crypto.publicEncrypt(
{
key: recipientPublicKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256',
},
aesKey
);
// Package everything together
const payload: EncryptedPayload = {
encryptedKey: encryptedKey.toString('base64'),
iv: iv.toString('base64'),
ciphertext: encrypted.toString('base64'),
authTag: authTag.toString('base64'),
};
return JSON.stringify(payload);
} catch (error) {
console.error('Failed to encrypt message:', error);
throw new Error('Encryption failed');
}
}
/**
* Decrypt a message using hybrid encryption (AES + RSA)
*/
export function decryptMessage(encryptedMessage: string, privateKey: string): string {
try {
// Parse the encrypted payload
const payload: EncryptedPayload = JSON.parse(encryptedMessage);
// Decrypt the AES key with RSA
const aesKey = crypto.privateDecrypt(
{
key: privateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256',
},
Buffer.from(payload.encryptedKey, 'base64')
);
// Decrypt the message with AES-256-GCM
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
aesKey,
Buffer.from(payload.iv, 'base64')
);
decipher.setAuthTag(Buffer.from(payload.authTag, 'base64'));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(payload.ciphertext, 'base64')),
decipher.final()
]);
return decrypted.toString('utf8');
} catch (error) {
console.error('Failed to decrypt message:', error);
throw new Error('Decryption failed');
}
}
/**
* Sign a message payload for authenticity verification
*/
export function signPayload(payload: string, privateKey: string): string {
try {
const sign = crypto.createSign('SHA256');
sign.update(payload);
sign.end();
const signature = sign.sign(privateKey, 'base64');
return signature;
} catch (error) {
console.error('Failed to sign payload:', error);
throw new Error('Signing failed');
}
}
/**
* Verify a signed payload
*/
export function verifySignature(payload: string, signature: string, publicKey: string): boolean {
try {
const verify = crypto.createVerify('SHA256');
verify.update(payload);
verify.end();
return verify.verify(publicKey, signature, 'base64');
} catch (error) {
console.error('Failed to verify signature:', error);
return false;
}
}
-66
View File
@@ -1,66 +0,0 @@
/**
* Swarm Chat Types
*
* Type definitions for the swarm chat system.
*/
export interface SwarmChatMessage {
id: string;
conversationId: string;
senderHandle: string;
senderDisplayName?: string;
senderAvatarUrl?: string;
senderNodeDomain?: string;
encryptedContent: string;
deliveredAt?: string;
readAt?: string;
createdAt: string;
}
export interface SwarmChatConversation {
id: string;
type: 'direct' | 'group';
participant1Id: string;
participant2Handle: string;
lastMessageAt?: string;
lastMessagePreview?: string;
unreadCount?: number;
createdAt: string;
updatedAt: string;
}
/**
* Payload for sending a chat message to a remote node
*/
export interface SwarmChatMessagePayload {
messageId: string;
senderHandle: string;
senderDisplayName?: string;
senderAvatarUrl?: string;
senderNodeDomain: string;
recipientHandle: string;
encryptedContent: string;
timestamp: string;
signature?: string;
}
/**
* Payload for typing indicator
*/
export interface SwarmChatTypingPayload {
senderHandle: string;
senderNodeDomain: string;
recipientHandle: string;
isTyping: boolean;
timestamp: string;
}
/**
* Payload for read receipt
*/
export interface SwarmChatReadReceiptPayload {
messageId: string;
readerHandle: string;
readerNodeDomain: string;
timestamp: string;
}
+2 -1
View File
@@ -17,7 +17,8 @@ export interface User {
isSwarm?: boolean; // Whether this user is from a Synapsis swarm node isSwarm?: boolean; // Whether this user is from a Synapsis swarm node
nodeDomain?: string | null; // Domain of the node this user is from (for swarm users) nodeDomain?: string | null; // Domain of the node this user is from (for swarm users)
did?: string; did?: string;
chatPublicKey?: string; canReceiveDms?: boolean;
dmPrivacy?: 'everyone' | 'following' | 'none';
botOwner?: { botOwner?: {
id: string; id: string;
handle: string; handle: string;