e2fa572e84
- Add swarm node registry with discovery, gossip, and timeline synchronization - Implement database schema for swarm nodes, seeds, and sync logs with proper indexing - Create swarm API endpoints for node discovery, gossip protocol, timeline sharing, and announcements - Add content moderation features with NSFW flagging and muted nodes support - Implement user settings pages for content filtering and node moderation - Add background scheduler for automated swarm synchronization and node health checks - Create .well-known endpoint for swarm protocol discovery - Remove legacy bot-cron.ts in favor of integrated scheduler system - Add user account NSFW preferences and age verification tracking - Update database schema with swarm-related tables and user moderation fields - Enhance post and user components to support federated content display - Add instrumentation for monitoring swarm operations and node health
312 lines
7.6 KiB
TypeScript
312 lines
7.6 KiB
TypeScript
/**
|
|
* Swarm Registry
|
|
*
|
|
* Manages the local registry of known swarm nodes.
|
|
*/
|
|
|
|
import { db, swarmNodes, swarmSeeds, swarmSyncLog } from '@/db';
|
|
import { eq, desc, and, gt, lt, sql } from 'drizzle-orm';
|
|
import type { SwarmNodeInfo, SwarmCapability, SwarmSyncResult } from './types';
|
|
import { SWARM_CONFIG, DEFAULT_SEED_NODES } from './types';
|
|
|
|
/**
|
|
* Get or create a swarm node entry
|
|
*/
|
|
export async function upsertSwarmNode(
|
|
node: SwarmNodeInfo,
|
|
discoveredVia?: string
|
|
): Promise<{ isNew: boolean }> {
|
|
if (!db) {
|
|
return { isNew: false };
|
|
}
|
|
|
|
const existing = await db.query.swarmNodes.findFirst({
|
|
where: eq(swarmNodes.domain, node.domain),
|
|
});
|
|
|
|
const capabilities = node.capabilities ? JSON.stringify(node.capabilities) : null;
|
|
|
|
if (!existing) {
|
|
await db.insert(swarmNodes).values({
|
|
domain: node.domain,
|
|
name: node.name,
|
|
description: node.description,
|
|
logoUrl: node.logoUrl,
|
|
publicKey: node.publicKey,
|
|
softwareVersion: node.softwareVersion,
|
|
userCount: node.userCount,
|
|
postCount: node.postCount,
|
|
isNsfw: node.isNsfw ?? false,
|
|
discoveredVia,
|
|
capabilities,
|
|
lastSeenAt: node.lastSeenAt ? new Date(node.lastSeenAt) : new Date(),
|
|
});
|
|
return { isNew: true };
|
|
}
|
|
|
|
// Update existing node
|
|
await db.update(swarmNodes)
|
|
.set({
|
|
name: node.name ?? existing.name,
|
|
description: node.description ?? existing.description,
|
|
logoUrl: node.logoUrl ?? existing.logoUrl,
|
|
publicKey: node.publicKey ?? existing.publicKey,
|
|
softwareVersion: node.softwareVersion ?? existing.softwareVersion,
|
|
userCount: node.userCount ?? existing.userCount,
|
|
postCount: node.postCount ?? existing.postCount,
|
|
isNsfw: node.isNsfw ?? existing.isNsfw,
|
|
capabilities: capabilities ?? existing.capabilities,
|
|
lastSeenAt: new Date(),
|
|
consecutiveFailures: 0,
|
|
isActive: true,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(swarmNodes.domain, node.domain));
|
|
|
|
return { isNew: false };
|
|
}
|
|
|
|
/**
|
|
* Bulk upsert swarm nodes from gossip
|
|
*/
|
|
export async function upsertSwarmNodes(
|
|
nodes: SwarmNodeInfo[],
|
|
discoveredVia: string
|
|
): Promise<{ added: number; updated: number }> {
|
|
if (!db || nodes.length === 0) {
|
|
return { added: 0, updated: 0 };
|
|
}
|
|
|
|
let added = 0;
|
|
let updated = 0;
|
|
|
|
// Filter out our own domain
|
|
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
|
const filteredNodes = nodes.filter(n => n.domain !== ourDomain);
|
|
|
|
for (const node of filteredNodes) {
|
|
const result = await upsertSwarmNode(node, discoveredVia);
|
|
if (result.isNew) {
|
|
added++;
|
|
} else {
|
|
updated++;
|
|
}
|
|
}
|
|
|
|
return { added, updated };
|
|
}
|
|
|
|
/**
|
|
* Get all active swarm nodes
|
|
*/
|
|
export async function getActiveSwarmNodes(limit = 100): Promise<SwarmNodeInfo[]> {
|
|
if (!db) {
|
|
return [];
|
|
}
|
|
|
|
const nodes = await db.query.swarmNodes.findMany({
|
|
where: eq(swarmNodes.isActive, true),
|
|
orderBy: [desc(swarmNodes.lastSeenAt)],
|
|
limit,
|
|
});
|
|
|
|
return nodes.map(nodeToInfo);
|
|
}
|
|
|
|
/**
|
|
* Get nodes for gossip (random selection of active nodes)
|
|
*/
|
|
export async function getNodesForGossip(count: number): Promise<SwarmNodeInfo[]> {
|
|
if (!db) {
|
|
return [];
|
|
}
|
|
|
|
// Get active nodes with decent trust scores, ordered randomly
|
|
const nodes = await db.query.swarmNodes.findMany({
|
|
where: and(
|
|
eq(swarmNodes.isActive, true),
|
|
gt(swarmNodes.trustScore, 20)
|
|
),
|
|
orderBy: sql`RANDOM()`,
|
|
limit: count,
|
|
});
|
|
|
|
return nodes.map(nodeToInfo);
|
|
}
|
|
|
|
/**
|
|
* Get nodes updated since a timestamp (for incremental sync)
|
|
*/
|
|
export async function getNodesSince(since: Date, limit = 100): Promise<SwarmNodeInfo[]> {
|
|
if (!db) {
|
|
return [];
|
|
}
|
|
|
|
const nodes = await db.query.swarmNodes.findMany({
|
|
where: gt(swarmNodes.updatedAt, since),
|
|
orderBy: [desc(swarmNodes.updatedAt)],
|
|
limit,
|
|
});
|
|
|
|
return nodes.map(nodeToInfo);
|
|
}
|
|
|
|
/**
|
|
* Mark a node as having failed contact
|
|
*/
|
|
export async function markNodeFailure(domain: string): Promise<void> {
|
|
if (!db) return;
|
|
|
|
const node = await db.query.swarmNodes.findFirst({
|
|
where: eq(swarmNodes.domain, domain),
|
|
});
|
|
|
|
if (!node) return;
|
|
|
|
const newFailures = node.consecutiveFailures + 1;
|
|
const newTrust = Math.max(
|
|
SWARM_CONFIG.minTrustScore,
|
|
node.trustScore + SWARM_CONFIG.trustScoreOnFailure
|
|
);
|
|
const isActive = newFailures < SWARM_CONFIG.maxConsecutiveFailures;
|
|
|
|
await db.update(swarmNodes)
|
|
.set({
|
|
consecutiveFailures: newFailures,
|
|
trustScore: newTrust,
|
|
isActive,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(swarmNodes.domain, domain));
|
|
}
|
|
|
|
/**
|
|
* Mark a node as successfully contacted
|
|
*/
|
|
export async function markNodeSuccess(domain: string): Promise<void> {
|
|
if (!db) return;
|
|
|
|
const node = await db.query.swarmNodes.findFirst({
|
|
where: eq(swarmNodes.domain, domain),
|
|
});
|
|
|
|
if (!node) return;
|
|
|
|
const newTrust = Math.min(
|
|
SWARM_CONFIG.maxTrustScore,
|
|
node.trustScore + SWARM_CONFIG.trustScoreOnSuccess
|
|
);
|
|
|
|
await db.update(swarmNodes)
|
|
.set({
|
|
consecutiveFailures: 0,
|
|
trustScore: newTrust,
|
|
isActive: true,
|
|
lastSeenAt: new Date(),
|
|
lastSyncAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(swarmNodes.domain, domain));
|
|
}
|
|
|
|
/**
|
|
* Log a sync operation
|
|
*/
|
|
export async function logSync(
|
|
remoteDomain: string,
|
|
direction: 'push' | 'pull',
|
|
result: SwarmSyncResult
|
|
): Promise<void> {
|
|
if (!db) return;
|
|
|
|
await db.insert(swarmSyncLog).values({
|
|
remoteDomain,
|
|
direction,
|
|
nodesReceived: result.nodesReceived,
|
|
nodesSent: result.nodesSent,
|
|
handlesReceived: result.handlesReceived,
|
|
handlesSent: result.handlesSent,
|
|
success: result.success,
|
|
errorMessage: result.error,
|
|
durationMs: result.durationMs,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get seed nodes (with fallback to defaults)
|
|
*/
|
|
export async function getSeedNodes(): Promise<string[]> {
|
|
if (!db) {
|
|
return [...DEFAULT_SEED_NODES];
|
|
}
|
|
|
|
const seeds = await db.query.swarmSeeds.findMany({
|
|
where: eq(swarmSeeds.isEnabled, true),
|
|
orderBy: [swarmSeeds.priority],
|
|
});
|
|
|
|
if (seeds.length === 0) {
|
|
return [...DEFAULT_SEED_NODES];
|
|
}
|
|
|
|
return seeds.map(s => s.domain);
|
|
}
|
|
|
|
/**
|
|
* Add a seed node
|
|
*/
|
|
export async function addSeedNode(domain: string, priority = 100): Promise<void> {
|
|
if (!db) return;
|
|
|
|
await db.insert(swarmSeeds)
|
|
.values({ domain, priority })
|
|
.onConflictDoUpdate({
|
|
target: swarmSeeds.domain,
|
|
set: { priority, isEnabled: true },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get swarm statistics
|
|
*/
|
|
export async function getSwarmStats() {
|
|
if (!db) {
|
|
return {
|
|
totalNodes: 0,
|
|
activeNodes: 0,
|
|
totalUsers: 0,
|
|
totalPosts: 0,
|
|
};
|
|
}
|
|
|
|
const allNodes = await db.query.swarmNodes.findMany();
|
|
const activeNodes = allNodes.filter(n => n.isActive);
|
|
|
|
const totalUsers = activeNodes.reduce((sum, n) => sum + (n.userCount || 0), 0);
|
|
const totalPosts = activeNodes.reduce((sum, n) => sum + (n.postCount || 0), 0);
|
|
|
|
return {
|
|
totalNodes: allNodes.length,
|
|
activeNodes: activeNodes.length,
|
|
totalUsers,
|
|
totalPosts,
|
|
};
|
|
}
|
|
|
|
// Helper to convert DB node to SwarmNodeInfo
|
|
function nodeToInfo(node: typeof swarmNodes.$inferSelect): SwarmNodeInfo {
|
|
return {
|
|
domain: node.domain,
|
|
name: node.name ?? undefined,
|
|
description: node.description ?? undefined,
|
|
logoUrl: node.logoUrl ?? undefined,
|
|
publicKey: node.publicKey ?? undefined,
|
|
softwareVersion: node.softwareVersion ?? undefined,
|
|
userCount: node.userCount ?? undefined,
|
|
postCount: node.postCount ?? undefined,
|
|
capabilities: node.capabilities ? JSON.parse(node.capabilities) : undefined,
|
|
isNsfw: node.isNsfw,
|
|
lastSeenAt: node.lastSeenAt.toISOString(),
|
|
};
|
|
}
|