diff --git a/src/app/api/chat/keys/route.ts b/src/app/api/chat/keys/route.ts index b252cf3..f4217b3 100644 --- a/src/app/api/chat/keys/route.ts +++ b/src/app/api/chat/keys/route.ts @@ -3,6 +3,7 @@ import { db } from '@/db'; import { chatDeviceBundles, handleRegistry, remoteIdentityCache } from '@/db/schema'; import { requireAuth } from '@/lib/auth'; import { eq, and, gt } from 'drizzle-orm'; +import { updateRegistryFromProfile } from '@/lib/swarm/healing'; /** * GET /api/chat/keys?did= @@ -115,6 +116,9 @@ export async function GET(request: NextRequest) { if (chatKey) { console.log('[Chat Keys GET] Found key in Swarm Profile'); + // HEALING: Update the registry because we found the user at this nodeDomain! + await updateRegistryFromProfile(handleEntry.handle, did, nodeDomain); + // Cache it await db.insert(remoteIdentityCache).values({ did: did, diff --git a/src/lib/federation/handles.ts b/src/lib/federation/handles.ts index 0e463d8..cd5d683 100644 --- a/src/lib/federation/handles.ts +++ b/src/lib/federation/handles.ts @@ -11,7 +11,11 @@ export type HandleEntry = { export const normalizeHandle = (handle: string) => handle.toLowerCase().replace(/^@/, '').trim(); -export async function upsertHandleEntries(entries: HandleEntry[]) { +// [Modified] Added sourceDomain parameter +export async function upsertHandleEntries( + entries: HandleEntry[], + sourceDomain?: string +) { if (!db) { return { added: 0, updated: 0 }; } @@ -29,6 +33,9 @@ export async function upsertHandleEntries(entries: HandleEntry[]) { where: eq(handleRegistry.handle, cleanHandle), }); + // If no timestamp provided, treat it as "now" but be careful + // Actually, if it's missing, it might be old data. + // But if it comes from the authoritative source, we might trust it. const incomingUpdatedAt = entry.updatedAt ? new Date(entry.updatedAt) : new Date(); if (!existing) { @@ -42,7 +49,22 @@ export async function upsertHandleEntries(entries: HandleEntry[]) { continue; } - if (!existing.updatedAt || incomingUpdatedAt > existing.updatedAt) { + // PROPAGATION FIX: + // 1. If the update comes from the node that OWNS the handle (sourceDomain == entry.nodeDomain), + // we treat it as authoritative. + // 2. We allow updates if the timestamp is newer OR equal (to handle clock skew). + // 3. We allow updates if the authoritative source is correcting a mismatch (e.g. we thought it was distinct, they say it's them). + + const isAuthoritative = sourceDomain && (sourceDomain === entry.nodeDomain); + const isNewerOrEqual = incomingUpdatedAt.getTime() >= (existing.updatedAt?.getTime() || 0); + + // If authoritative, we accept it even if timestamps are identical (recovery) + // If not authoritative, only accept strictly newer + const shouldUpdate = isAuthoritative + ? isNewerOrEqual || (existing.nodeDomain !== entry.nodeDomain) // Auto-correct wrong domain + : incomingUpdatedAt > (existing.updatedAt || new Date(0)); + + if (shouldUpdate) { await db.update(handleRegistry) .set({ did: entry.did, diff --git a/src/lib/swarm/gossip.ts b/src/lib/swarm/gossip.ts index 69cd900..3ede9a3 100644 --- a/src/lib/swarm/gossip.ts +++ b/src/lib/swarm/gossip.ts @@ -9,9 +9,9 @@ import { db, handleRegistry } from '@/db'; import { desc, gt } from 'drizzle-orm'; import type { SwarmGossipPayload, SwarmGossipResponse, SwarmSyncResult, SwarmNodeInfo } from './types'; import { SWARM_CONFIG } from './types'; -import { - getNodesForGossip, - getActiveSwarmNodes, +import { + getNodesForGossip, + getActiveSwarmNodes, getNodesSince, upsertSwarmNodes, markNodeSuccess, @@ -26,7 +26,7 @@ import { buildAnnouncement } from './discovery'; */ export async function buildGossipPayload(since?: string): Promise { const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; - + // Get nodes to share let nodes: SwarmNodeInfo[]; if (since) { @@ -84,14 +84,15 @@ export async function processGossip( payload: SwarmGossipPayload ): Promise { const startTime = Date.now(); - + // Process incoming nodes const nodeResult = await upsertSwarmNodes(payload.nodes, payload.sender); - + // Process incoming handles let handlesResult = { added: 0, updated: 0 }; if (payload.handles && payload.handles.length > 0) { - handlesResult = await upsertHandleEntries(payload.handles); + // PASS SENDER: This allows us to trust "authoritative" updates from the node itself + handlesResult = await upsertHandleEntries(payload.handles, payload.sender); } // Build our response with nodes/handles to share back @@ -115,10 +116,10 @@ export async function gossipToNode( since?: string ): Promise { const startTime = Date.now(); - + try { const payload = await buildGossipPayload(since); - + const baseUrl = targetDomain.startsWith('http') ? targetDomain : `https://${targetDomain}`; const url = `${baseUrl}/api/swarm/gossip`; @@ -160,10 +161,11 @@ export async function gossipToNode( // Process the response (nodes and handles they sent back) const nodeResult = await upsertSwarmNodes(gossipResponse.nodes, targetDomain); - + let handlesResult = { added: 0, updated: 0 }; if (gossipResponse.handles && gossipResponse.handles.length > 0) { - handlesResult = await upsertHandleEntries(gossipResponse.handles); + // PASS TARGET: If we gossiped TO them, and they replied, treat their reply as authoritative + handlesResult = await upsertHandleEntries(gossipResponse.handles, targetDomain); } await markNodeSuccess(targetDomain); @@ -182,9 +184,9 @@ export async function gossipToNode( } catch (error) { const durationMs = Date.now() - startTime; const errorMsg = error instanceof Error ? error.message : 'Unknown error'; - + await markNodeFailure(targetDomain); - + const result: SwarmSyncResult = { success: false, nodesReceived: 0, @@ -194,7 +196,7 @@ export async function gossipToNode( error: errorMsg, durationMs, }; - + await logSync(targetDomain, 'push', result); return result; } @@ -211,7 +213,7 @@ export async function runGossipRound(): Promise<{ }> { // Get random nodes to gossip with const targets = await getNodesForGossip(SWARM_CONFIG.gossipFanout); - + let contacted = 0; let successful = 0; let totalNodesReceived = 0; @@ -220,7 +222,7 @@ export async function runGossipRound(): Promise<{ for (const target of targets) { contacted++; const result = await gossipToNode(target.domain); - + if (result.success) { successful++; totalNodesReceived += result.nodesReceived; diff --git a/src/lib/swarm/healing.ts b/src/lib/swarm/healing.ts new file mode 100644 index 0000000..6eb4342 --- /dev/null +++ b/src/lib/swarm/healing.ts @@ -0,0 +1,65 @@ + +import { db, handleRegistry, users } from '@/db'; +import { eq } from 'drizzle-orm'; +import { gossipToNode } from './gossip'; +import { fetchSwarmUserProfile, fetchSwarmPost } from './interactions'; +import { upsertHandleEntries } from '@/lib/federation/handles'; + +/** + * Attempt to "heal" a connection to a user by finding their correct node + * and syncing with it. + * + * @param did - The DID of the user we are trying to reach + * @param knownDomain - The last known domain (that failed) + */ +export async function healNodeConnection(did: string, knownDomain?: string): Promise { + console.log(`[Swarm Healing] Attempting to heal connection for DID: ${did} (Last known: ${knownDomain})`); + + // 1. Try to find logic to recover the domain + // If we have a handle locally, we can try to resolve it via other means? + // Actually, if we have the DID, we might be able to query a "seed node" if we had one. + // For now, let's assume we might have the wrong domain in the registry, + // OR the registry is correct but we just haven't gossiped recently. + + // If we have a known domain (that failed 404), maybe we should try to GOSSIP with it? + // Sometimes a node knows about itself but we haven't synced it. + if (knownDomain) { + console.log(`[Swarm Healing] Triggering forced gossip with ${knownDomain}`); + try { + const result = await gossipToNode(knownDomain); + if (result.success && result.handlesReceived > 0) { + console.log(`[Swarm Healing] Gossip successful, handles updated.`); + // Check if our specific user was updated? + // We trust upsertHandleEntries handled it if authoritative. + return true; + } + } catch (e) { + console.error(`[Swarm Healing] Gossip to ${knownDomain} failed:`, e); + } + } + + // 2. Fallback: If we know the user's handle but NOT the domain (or domain failed completely) + // We can try to guess or use a fallback mechanism. + + // Let's try to look up the user in our local "Users" table to see if we have a clue? + // Or assume the formatting of the DID might give a hint? (Did is meaningless usually). + + return false; +} + +/** + * "Soft" Heal: We successfully fetched a user profile from a URL, + * but our registry might be out of date. Update it immediately. + */ +export async function updateRegistryFromProfile(handle: string, did: string, nodeDomain: string) { + if (!handle || !did || !nodeDomain) return; + + console.log(`[Swarm Healing] Updating registry from valid profile fetch: ${handle}@${nodeDomain}`); + + await upsertHandleEntries([{ + handle, + did, + nodeDomain, + updatedAt: new Date().toISOString() + }], nodeDomain); +}