refactor: Migrate from ActivityPub federation to native Swarm network
- Remove ActivityPub infrastructure (webfinger, nodeinfo, inbox, activities, signatures) - Implement native peer-to-peer Swarm network with gossip protocol - Replace federated timeline with Swarm timeline aggregating posts from all nodes - Migrate direct messaging to end-to-end encrypted Swarm Chat system - Update user interactions (follow, like, repost) to use Swarm protocol - Add cryptographic key management for DID-based identity system - Remove server-bound identity model in favor of portable DID identities - Update documentation to reflect Swarm architecture and capabilities - Add debug utilities and chat testing tools for Swarm network - Refactor database schema to support distributed user directory - Update bot framework to work with Swarm network interactions - Simplify API routes to use Swarm protocol instead of ActivityPub - This transition enables true peer-to-peer communication, instant interactions, and encrypted messaging while maintaining sovereign identity through DIDs
This commit is contained in:
@@ -3,10 +3,6 @@ import crypto from 'crypto';
|
||||
import { db, follows, users, notifications, remoteFollows } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
|
||||
import { createFollowActivity, createUndoActivity } from '@/lib/activitypub/activities';
|
||||
import { deliverActivity } from '@/lib/activitypub/outbox';
|
||||
import { cacheRemoteUserPosts } from '@/lib/activitypub/cache';
|
||||
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions';
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
@@ -20,12 +16,6 @@ const parseRemoteHandle = (handle: string) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Strip HTML tags from a string (for Mastodon bios that come as HTML)
|
||||
const stripHtml = (html: string | null | undefined): string | null => {
|
||||
if (!html) return null;
|
||||
return html.replace(/<[^>]*>/g, '').trim() || null;
|
||||
};
|
||||
|
||||
// Check follow status
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
@@ -115,98 +105,42 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
return NextResponse.json({ error: 'Already following' }, { status: 400 });
|
||||
}
|
||||
|
||||
// SWARM-FIRST: Check if this is a Synapsis swarm node
|
||||
// Only allow following swarm nodes
|
||||
const isSwarm = await isSwarmNode(remote.domain);
|
||||
|
||||
if (isSwarm) {
|
||||
// Use swarm protocol for Synapsis nodes
|
||||
const activityId = crypto.randomUUID();
|
||||
|
||||
const result = await deliverSwarmFollow(remote.domain, {
|
||||
targetHandle: remote.handle,
|
||||
follow: {
|
||||
followerHandle: currentUser.handle,
|
||||
followerDisplayName: currentUser.displayName || currentUser.handle,
|
||||
followerAvatarUrl: currentUser.avatarUrl || undefined,
|
||||
followerBio: currentUser.bio || undefined,
|
||||
followerNodeDomain: nodeDomain,
|
||||
interactionId: activityId,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.warn(`[Swarm] Follow delivery failed, falling back to ActivityPub: ${result.error}`);
|
||||
// Fall through to ActivityPub below
|
||||
} else {
|
||||
// Swarm follow succeeded - store the follow locally
|
||||
await db.insert(remoteFollows).values({
|
||||
followerId: currentUser.id,
|
||||
targetHandle,
|
||||
targetActorUrl: `swarm://${remote.domain}/${remote.handle}`,
|
||||
inboxUrl: `https://${remote.domain}/api/swarm/interactions/inbox`,
|
||||
activityId,
|
||||
displayName: null, // Will be fetched later
|
||||
bio: null,
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
// Update the user's following count
|
||||
await db.update(users)
|
||||
.set({ followingCount: currentUser.followingCount + 1 })
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
// Cache the remote user's recent posts in the background
|
||||
cacheSwarmUserPosts(remote.handle, remote.domain, targetHandle, 20)
|
||||
.then(result => console.log(`[Swarm] Cached ${result.cached} posts for ${targetHandle}`))
|
||||
.catch(err => console.error('[Swarm] Error caching remote posts:', err));
|
||||
|
||||
console.log(`[Swarm] Follow delivered to ${remote.domain} for @${remote.handle}`);
|
||||
return NextResponse.json({ success: true, following: true, remote: true, swarm: true });
|
||||
}
|
||||
}
|
||||
|
||||
// FALLBACK: Use ActivityPub for non-swarm nodes or if swarm failed
|
||||
const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain);
|
||||
if (!remoteProfile) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
const targetInbox = remoteProfile.endpoints?.sharedInbox || remoteProfile.inbox;
|
||||
if (!targetInbox) {
|
||||
return NextResponse.json({ error: 'Remote inbox not available' }, { status: 400 });
|
||||
if (!isSwarm) {
|
||||
return NextResponse.json({ error: 'Can only follow users on Synapsis swarm nodes' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Use swarm protocol
|
||||
const activityId = crypto.randomUUID();
|
||||
const followActivity = createFollowActivity(currentUser, remoteProfile.id, nodeDomain, activityId);
|
||||
const keyId = `https://${nodeDomain}/users/${currentUser.handle}#main-key`;
|
||||
const privateKey = currentUser.privateKeyEncrypted;
|
||||
if (!privateKey) {
|
||||
return NextResponse.json({ error: 'Missing signing key' }, { status: 500 });
|
||||
}
|
||||
const result = await deliverActivity(followActivity, targetInbox, privateKey, keyId);
|
||||
|
||||
const result = await deliverSwarmFollow(remote.domain, {
|
||||
targetHandle: remote.handle,
|
||||
follow: {
|
||||
followerHandle: currentUser.handle,
|
||||
followerDisplayName: currentUser.displayName || currentUser.handle,
|
||||
followerAvatarUrl: currentUser.avatarUrl || undefined,
|
||||
followerBio: currentUser.bio || undefined,
|
||||
followerNodeDomain: nodeDomain,
|
||||
interactionId: activityId,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error || 'Failed to follow remote user' }, { status: 502 });
|
||||
}
|
||||
|
||||
// Extract avatar URL from remote profile
|
||||
let avatarUrl: string | null = null;
|
||||
if (remoteProfile.icon) {
|
||||
if (typeof remoteProfile.icon === 'string') {
|
||||
avatarUrl = remoteProfile.icon;
|
||||
} else if (typeof remoteProfile.icon === 'object' && remoteProfile.icon.url) {
|
||||
avatarUrl = remoteProfile.icon.url;
|
||||
}
|
||||
return NextResponse.json({ error: result.error || 'Failed to follow user' }, { status: 502 });
|
||||
}
|
||||
|
||||
// Store the follow locally
|
||||
await db.insert(remoteFollows).values({
|
||||
followerId: currentUser.id,
|
||||
targetHandle,
|
||||
targetActorUrl: remoteProfile.id,
|
||||
inboxUrl: targetInbox,
|
||||
targetActorUrl: `swarm://${remote.domain}/${remote.handle}`,
|
||||
inboxUrl: `https://${remote.domain}/api/swarm/interactions/inbox`,
|
||||
activityId,
|
||||
displayName: remoteProfile.name || null,
|
||||
bio: stripHtml(remoteProfile.summary),
|
||||
avatarUrl,
|
||||
displayName: null,
|
||||
bio: null,
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
// Update the user's following count
|
||||
@@ -215,12 +149,12 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
// Cache the remote user's recent posts in the background
|
||||
const origin = new URL(request.url).origin;
|
||||
cacheRemoteUserPosts(remoteProfile, targetHandle, origin, 20)
|
||||
.then(result => console.log(`Cached ${result.cached} posts for ${targetHandle}`))
|
||||
.catch(err => console.error('Error caching remote posts:', err));
|
||||
cacheSwarmUserPosts(remote.handle, remote.domain, targetHandle, 20)
|
||||
.then(result => console.log(`[Swarm] Cached ${result.cached} posts for ${targetHandle}`))
|
||||
.catch(err => console.error('[Swarm] Error caching remote posts:', err));
|
||||
|
||||
return NextResponse.json({ success: true, following: true, remote: true });
|
||||
console.log(`[Swarm] Follow delivered to ${remote.domain} for @${remote.handle}`);
|
||||
return NextResponse.json({ success: true, following: true, remote: true, swarm: true });
|
||||
}
|
||||
|
||||
if (!db) {
|
||||
@@ -263,14 +197,14 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
});
|
||||
|
||||
if (currentUser.id !== targetUser.id) {
|
||||
// Create notification with actor info stored directly
|
||||
// Create notification
|
||||
await db.insert(notifications).values({
|
||||
userId: targetUser.id,
|
||||
actorId: currentUser.id,
|
||||
actorHandle: currentUser.handle,
|
||||
actorDisplayName: currentUser.displayName,
|
||||
actorAvatarUrl: currentUser.avatarUrl,
|
||||
actorNodeDomain: null, // Local user
|
||||
actorNodeDomain: null,
|
||||
type: 'follow',
|
||||
});
|
||||
|
||||
@@ -297,8 +231,6 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
.set({ followersCount: targetUser.followersCount + 1 })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
// TODO: Send ActivityPub Follow activity
|
||||
|
||||
return NextResponse.json({ success: true, following: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
@@ -333,55 +265,23 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
return NextResponse.json({ error: 'Not following' }, { status: 400 });
|
||||
}
|
||||
|
||||
// SWARM-FIRST: Check if this is a swarm follow (swarm:// actor URL)
|
||||
const isSwarmFollow = existingRemoteFollow.targetActorUrl.startsWith('swarm://');
|
||||
|
||||
if (isSwarmFollow) {
|
||||
// Use swarm protocol for unfollow
|
||||
const result = await deliverSwarmUnfollow(remote.domain, {
|
||||
targetHandle: remote.handle,
|
||||
unfollow: {
|
||||
followerHandle: currentUser.handle,
|
||||
followerNodeDomain: nodeDomain,
|
||||
interactionId: crypto.randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
// Use swarm protocol for unfollow
|
||||
const result = await deliverSwarmUnfollow(remote.domain, {
|
||||
targetHandle: remote.handle,
|
||||
unfollow: {
|
||||
followerHandle: currentUser.handle,
|
||||
followerNodeDomain: nodeDomain,
|
||||
interactionId: crypto.randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.warn(`[Swarm] Unfollow delivery failed: ${result.error}`);
|
||||
// Continue anyway - remove local record
|
||||
}
|
||||
|
||||
// Remove the follow record
|
||||
await db.delete(remoteFollows).where(eq(remoteFollows.id, existingRemoteFollow.id));
|
||||
|
||||
// Update the user's following count
|
||||
await db.update(users)
|
||||
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
console.log(`[Swarm] Unfollow delivered to ${remote.domain}`);
|
||||
return NextResponse.json({ success: true, following: false, remote: true, swarm: true });
|
||||
}
|
||||
|
||||
// FALLBACK: Use ActivityPub for non-swarm follows
|
||||
const originalFollow = createFollowActivity(
|
||||
currentUser,
|
||||
existingRemoteFollow.targetActorUrl,
|
||||
nodeDomain,
|
||||
existingRemoteFollow.activityId
|
||||
);
|
||||
const undoActivity = createUndoActivity(currentUser, originalFollow, nodeDomain, crypto.randomUUID());
|
||||
const keyId = `https://${nodeDomain}/users/${currentUser.handle}#main-key`;
|
||||
const privateKey = currentUser.privateKeyEncrypted;
|
||||
if (!privateKey) {
|
||||
return NextResponse.json({ error: 'Missing signing key' }, { status: 500 });
|
||||
}
|
||||
const result = await deliverActivity(undoActivity, existingRemoteFollow.inboxUrl, privateKey, keyId);
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error || 'Failed to unfollow remote user' }, { status: 502 });
|
||||
console.warn(`[Swarm] Unfollow delivery failed: ${result.error}`);
|
||||
// Continue anyway - remove local record
|
||||
}
|
||||
|
||||
// Remove the follow record
|
||||
await db.delete(remoteFollows).where(eq(remoteFollows.id, existingRemoteFollow.id));
|
||||
|
||||
// Update the user's following count
|
||||
@@ -389,7 +289,8 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
return NextResponse.json({ success: true, following: false, remote: true });
|
||||
console.log(`[Swarm] Unfollow delivered to ${remote.domain}`);
|
||||
return NextResponse.json({ success: true, following: false, remote: true, swarm: true });
|
||||
}
|
||||
|
||||
if (!db) {
|
||||
@@ -432,8 +333,6 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
.set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
// TODO: Send ActivityPub Undo Follow activity
|
||||
|
||||
return NextResponse.json({ success: true, following: false });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
|
||||
Reference in New Issue
Block a user