Improve handle registry healing and gossip propagation
Added healing utilities to update the handle registry when a user profile is found at a new node domain. Enhanced upsertHandleEntries to accept a sourceDomain parameter, allowing authoritative updates from the owning node. Updated gossip logic to pass sender/target domains for proper trust handling during handle propagation. Integrated healing logic into chat key lookup to keep the registry accurate.
This commit is contained in:
@@ -3,6 +3,7 @@ import { db } from '@/db';
|
|||||||
import { chatDeviceBundles, handleRegistry, remoteIdentityCache } from '@/db/schema';
|
import { chatDeviceBundles, handleRegistry, remoteIdentityCache } from '@/db/schema';
|
||||||
import { requireAuth } from '@/lib/auth';
|
import { requireAuth } from '@/lib/auth';
|
||||||
import { eq, and, gt } from 'drizzle-orm';
|
import { eq, and, gt } from 'drizzle-orm';
|
||||||
|
import { updateRegistryFromProfile } from '@/lib/swarm/healing';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/chat/keys?did=<did>
|
* GET /api/chat/keys?did=<did>
|
||||||
@@ -115,6 +116,9 @@ export async function GET(request: NextRequest) {
|
|||||||
if (chatKey) {
|
if (chatKey) {
|
||||||
console.log('[Chat Keys GET] Found key in Swarm Profile');
|
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
|
// Cache it
|
||||||
await db.insert(remoteIdentityCache).values({
|
await db.insert(remoteIdentityCache).values({
|
||||||
did: did,
|
did: did,
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ export type HandleEntry = {
|
|||||||
export const normalizeHandle = (handle: string) =>
|
export const normalizeHandle = (handle: string) =>
|
||||||
handle.toLowerCase().replace(/^@/, '').trim();
|
handle.toLowerCase().replace(/^@/, '').trim();
|
||||||
|
|
||||||
export async function upsertHandleEntries(entries: HandleEntry[]) {
|
// [Modified] Added sourceDomain parameter
|
||||||
|
export async function upsertHandleEntries(
|
||||||
|
entries: HandleEntry[],
|
||||||
|
sourceDomain?: string
|
||||||
|
) {
|
||||||
if (!db) {
|
if (!db) {
|
||||||
return { added: 0, updated: 0 };
|
return { added: 0, updated: 0 };
|
||||||
}
|
}
|
||||||
@@ -29,6 +33,9 @@ export async function upsertHandleEntries(entries: HandleEntry[]) {
|
|||||||
where: eq(handleRegistry.handle, cleanHandle),
|
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();
|
const incomingUpdatedAt = entry.updatedAt ? new Date(entry.updatedAt) : new Date();
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
@@ -42,7 +49,22 @@ export async function upsertHandleEntries(entries: HandleEntry[]) {
|
|||||||
continue;
|
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)
|
await db.update(handleRegistry)
|
||||||
.set({
|
.set({
|
||||||
did: entry.did,
|
did: entry.did,
|
||||||
|
|||||||
+18
-16
@@ -9,9 +9,9 @@ import { db, handleRegistry } from '@/db';
|
|||||||
import { desc, gt } from 'drizzle-orm';
|
import { desc, gt } from 'drizzle-orm';
|
||||||
import type { SwarmGossipPayload, SwarmGossipResponse, SwarmSyncResult, SwarmNodeInfo } from './types';
|
import type { SwarmGossipPayload, SwarmGossipResponse, SwarmSyncResult, SwarmNodeInfo } from './types';
|
||||||
import { SWARM_CONFIG } from './types';
|
import { SWARM_CONFIG } from './types';
|
||||||
import {
|
import {
|
||||||
getNodesForGossip,
|
getNodesForGossip,
|
||||||
getActiveSwarmNodes,
|
getActiveSwarmNodes,
|
||||||
getNodesSince,
|
getNodesSince,
|
||||||
upsertSwarmNodes,
|
upsertSwarmNodes,
|
||||||
markNodeSuccess,
|
markNodeSuccess,
|
||||||
@@ -26,7 +26,7 @@ import { buildAnnouncement } from './discovery';
|
|||||||
*/
|
*/
|
||||||
export async function buildGossipPayload(since?: string): Promise<SwarmGossipPayload> {
|
export async function buildGossipPayload(since?: string): Promise<SwarmGossipPayload> {
|
||||||
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||||
|
|
||||||
// Get nodes to share
|
// Get nodes to share
|
||||||
let nodes: SwarmNodeInfo[];
|
let nodes: SwarmNodeInfo[];
|
||||||
if (since) {
|
if (since) {
|
||||||
@@ -84,14 +84,15 @@ export async function processGossip(
|
|||||||
payload: SwarmGossipPayload
|
payload: SwarmGossipPayload
|
||||||
): Promise<SwarmGossipResponse> {
|
): Promise<SwarmGossipResponse> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
// Process incoming nodes
|
// Process incoming nodes
|
||||||
const nodeResult = await upsertSwarmNodes(payload.nodes, payload.sender);
|
const nodeResult = await upsertSwarmNodes(payload.nodes, payload.sender);
|
||||||
|
|
||||||
// Process incoming handles
|
// Process incoming handles
|
||||||
let handlesResult = { added: 0, updated: 0 };
|
let handlesResult = { added: 0, updated: 0 };
|
||||||
if (payload.handles && payload.handles.length > 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
|
// Build our response with nodes/handles to share back
|
||||||
@@ -115,10 +116,10 @@ export async function gossipToNode(
|
|||||||
since?: string
|
since?: string
|
||||||
): Promise<SwarmSyncResult> {
|
): Promise<SwarmSyncResult> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await buildGossipPayload(since);
|
const payload = await buildGossipPayload(since);
|
||||||
|
|
||||||
const baseUrl = targetDomain.startsWith('http') ? targetDomain : `https://${targetDomain}`;
|
const baseUrl = targetDomain.startsWith('http') ? targetDomain : `https://${targetDomain}`;
|
||||||
const url = `${baseUrl}/api/swarm/gossip`;
|
const url = `${baseUrl}/api/swarm/gossip`;
|
||||||
|
|
||||||
@@ -160,10 +161,11 @@ export async function gossipToNode(
|
|||||||
|
|
||||||
// Process the response (nodes and handles they sent back)
|
// Process the response (nodes and handles they sent back)
|
||||||
const nodeResult = await upsertSwarmNodes(gossipResponse.nodes, targetDomain);
|
const nodeResult = await upsertSwarmNodes(gossipResponse.nodes, targetDomain);
|
||||||
|
|
||||||
let handlesResult = { added: 0, updated: 0 };
|
let handlesResult = { added: 0, updated: 0 };
|
||||||
if (gossipResponse.handles && gossipResponse.handles.length > 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);
|
await markNodeSuccess(targetDomain);
|
||||||
@@ -182,9 +184,9 @@ export async function gossipToNode(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const durationMs = Date.now() - startTime;
|
const durationMs = Date.now() - startTime;
|
||||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|
||||||
await markNodeFailure(targetDomain);
|
await markNodeFailure(targetDomain);
|
||||||
|
|
||||||
const result: SwarmSyncResult = {
|
const result: SwarmSyncResult = {
|
||||||
success: false,
|
success: false,
|
||||||
nodesReceived: 0,
|
nodesReceived: 0,
|
||||||
@@ -194,7 +196,7 @@ export async function gossipToNode(
|
|||||||
error: errorMsg,
|
error: errorMsg,
|
||||||
durationMs,
|
durationMs,
|
||||||
};
|
};
|
||||||
|
|
||||||
await logSync(targetDomain, 'push', result);
|
await logSync(targetDomain, 'push', result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -211,7 +213,7 @@ export async function runGossipRound(): Promise<{
|
|||||||
}> {
|
}> {
|
||||||
// Get random nodes to gossip with
|
// Get random nodes to gossip with
|
||||||
const targets = await getNodesForGossip(SWARM_CONFIG.gossipFanout);
|
const targets = await getNodesForGossip(SWARM_CONFIG.gossipFanout);
|
||||||
|
|
||||||
let contacted = 0;
|
let contacted = 0;
|
||||||
let successful = 0;
|
let successful = 0;
|
||||||
let totalNodesReceived = 0;
|
let totalNodesReceived = 0;
|
||||||
@@ -220,7 +222,7 @@ export async function runGossipRound(): Promise<{
|
|||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
contacted++;
|
contacted++;
|
||||||
const result = await gossipToNode(target.domain);
|
const result = await gossipToNode(target.domain);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
successful++;
|
successful++;
|
||||||
totalNodesReceived += result.nodesReceived;
|
totalNodesReceived += result.nodesReceived;
|
||||||
|
|||||||
@@ -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<boolean> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user