Restrict public swarm participation and propagation to real public ICANN domains
Hop-State: A_06FP8GM0PKJTMF45B7016J0 Hop-Proposal: R_06FP8GKFXXZG84ETVCPS5ZG Hop-Task: T_06FP8F45T3G4Z4ESW9NZQQR Hop-Attempt: AT_06FP8F45T3QS81M2ZM4SJ3G
This commit is contained in:
Generated
+19
@@ -24,6 +24,7 @@
|
|||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
"tldts": "^7.4.8",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
@@ -9737,6 +9738,24 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tldts": {
|
||||||
|
"version": "7.4.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.8.tgz",
|
||||||
|
"integrity": "sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tldts-core": "^7.4.8"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tldts": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tldts-core": {
|
||||||
|
"version": "7.4.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.8.tgz",
|
||||||
|
"integrity": "sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
"tldts": "^7.4.8",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { upsertSwarmNode } from '@/lib/swarm/registry';
|
|||||||
import { buildAnnouncement } from '@/lib/swarm/discovery';
|
import { buildAnnouncement } from '@/lib/swarm/discovery';
|
||||||
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||||
import type { SwarmNodeInfo } from '@/lib/swarm/types';
|
import type { SwarmNodeInfo } from '@/lib/swarm/types';
|
||||||
|
import { getPublicSwarmDomain, isPublicSwarmDomain } from '@/lib/swarm/node-domain';
|
||||||
|
|
||||||
const optionalUrlSchema = z.preprocess((value) => {
|
const optionalUrlSchema = z.preprocess((value) => {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
@@ -55,9 +56,23 @@ export async function POST(request: Request) {
|
|||||||
const data = signedAnnouncementSchema.parse(body);
|
const data = signedAnnouncementSchema.parse(body);
|
||||||
|
|
||||||
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
||||||
|
|
||||||
|
if (!isPublicSwarmDomain(ourDomain)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'This node is not configured for public swarm participation' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPublicSwarmDomain(data.domain)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Swarm nodes must use a public ICANN domain' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Don't process announcements from ourselves
|
// Don't process announcements from ourselves
|
||||||
if (data.domain === ourDomain) {
|
if (getPublicSwarmDomain(data.domain) === getPublicSwarmDomain(ourDomain)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Cannot announce to self' },
|
{ error: 'Cannot announce to self' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { processGossip } from '@/lib/swarm/gossip';
|
|||||||
import { markNodeSuccess } from '@/lib/swarm/registry';
|
import { markNodeSuccess } from '@/lib/swarm/registry';
|
||||||
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||||
import type { SwarmGossipPayload } from '@/lib/swarm/types';
|
import type { SwarmGossipPayload } from '@/lib/swarm/types';
|
||||||
|
import { getPublicSwarmDomain, isPublicSwarmDomain } from '@/lib/swarm/node-domain';
|
||||||
|
|
||||||
const handleSchema = z.object({
|
const handleSchema = z.object({
|
||||||
handle: z.string(),
|
handle: z.string(),
|
||||||
@@ -61,9 +62,23 @@ export async function POST(request: Request) {
|
|||||||
const data = signedGossipSchema.parse(body);
|
const data = signedGossipSchema.parse(body);
|
||||||
|
|
||||||
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
||||||
|
|
||||||
|
if (!isPublicSwarmDomain(ourDomain)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'This node is not configured for public swarm participation' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPublicSwarmDomain(data.sender)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Swarm nodes must use a public ICANN domain' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Don't process gossip from ourselves
|
// Don't process gossip from ourselves
|
||||||
if (data.sender === ourDomain) {
|
if (getPublicSwarmDomain(data.sender) === getPublicSwarmDomain(ourDomain)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Cannot gossip with self' },
|
{ error: 'Cannot gossip with self' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
discoverNode,
|
discoverNode,
|
||||||
} from '@/lib/swarm/discovery';
|
} from '@/lib/swarm/discovery';
|
||||||
import { runGossipRound, gossipToNode } from '@/lib/swarm/gossip';
|
import { runGossipRound, gossipToNode } from '@/lib/swarm/gossip';
|
||||||
|
import { isPublicSwarmDomain } from '@/lib/swarm/node-domain';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/swarm/nodes
|
* GET /api/swarm/nodes
|
||||||
@@ -75,6 +76,13 @@ export async function POST(request: Request) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { action, domain, priority } = actionSchema.parse(body);
|
const { action, domain, priority } = actionSchema.parse(body);
|
||||||
|
|
||||||
|
if (domain && !isPublicSwarmDomain(domain)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Swarm nodes must use a public ICANN domain' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'announce': {
|
case 'announce': {
|
||||||
if (domain) {
|
if (domain) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { runGossipRound } from '@/lib/swarm/gossip';
|
|||||||
import { announceToSeeds } from '@/lib/swarm/discovery';
|
import { announceToSeeds } from '@/lib/swarm/discovery';
|
||||||
import { getSwarmStats } from '@/lib/swarm/registry';
|
import { getSwarmStats } from '@/lib/swarm/registry';
|
||||||
import { syncRemoteFollowsPosts } from '@/lib/background/remote-sync';
|
import { syncRemoteFollowsPosts } from '@/lib/background/remote-sync';
|
||||||
|
import { isPublicSwarmDomain } from '@/lib/swarm/node-domain';
|
||||||
|
|
||||||
const BOT_INTERVAL_MS = 60 * 1000; // 1 minute
|
const BOT_INTERVAL_MS = 60 * 1000; // 1 minute
|
||||||
const GOSSIP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
const GOSSIP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
@@ -121,6 +122,7 @@ export function startBackgroundTasks(origin?: string) {
|
|||||||
|
|
||||||
// Default origin for remote sync (can be overridden)
|
// Default origin for remote sync (can be overridden)
|
||||||
const syncOrigin = origin || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:43821';
|
const syncOrigin = origin || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:43821';
|
||||||
|
const publicSwarmEnabled = isPublicSwarmDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN);
|
||||||
|
|
||||||
log('STARTUP', 'Background task scheduler starting...');
|
log('STARTUP', 'Background task scheduler starting...');
|
||||||
log('STARTUP', `Bot interval: ${BOT_INTERVAL_MS / 1000}s, Gossip interval: ${GOSSIP_INTERVAL_MS / 1000}s, Remote sync interval: ${REMOTE_SYNC_INTERVAL_MS / 1000}s`);
|
log('STARTUP', `Bot interval: ${BOT_INTERVAL_MS / 1000}s, Gossip interval: ${GOSSIP_INTERVAL_MS / 1000}s, Remote sync interval: ${REMOTE_SYNC_INTERVAL_MS / 1000}s`);
|
||||||
@@ -129,8 +131,12 @@ export function startBackgroundTasks(origin?: string) {
|
|||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
log('STARTUP', 'Starting background tasks...');
|
log('STARTUP', 'Starting background tasks...');
|
||||||
|
|
||||||
// Announce to swarm on startup
|
if (publicSwarmEnabled) {
|
||||||
await announceToSwarm();
|
// Announce to swarm on startup
|
||||||
|
await announceToSwarm();
|
||||||
|
} else {
|
||||||
|
log('SWARM', 'Public swarm disabled: NEXT_PUBLIC_NODE_DOMAIN is not a public ICANN domain');
|
||||||
|
}
|
||||||
|
|
||||||
// Run initial bot check
|
// Run initial bot check
|
||||||
await runBotTasks();
|
await runBotTasks();
|
||||||
@@ -140,11 +146,15 @@ export function startBackgroundTasks(origin?: string) {
|
|||||||
|
|
||||||
// Schedule recurring tasks
|
// Schedule recurring tasks
|
||||||
setInterval(runBotTasks, BOT_INTERVAL_MS);
|
setInterval(runBotTasks, BOT_INTERVAL_MS);
|
||||||
setInterval(runSwarmGossip, GOSSIP_INTERVAL_MS);
|
if (publicSwarmEnabled) {
|
||||||
|
setInterval(runSwarmGossip, GOSSIP_INTERVAL_MS);
|
||||||
|
}
|
||||||
setInterval(() => runRemoteSync(syncOrigin), REMOTE_SYNC_INTERVAL_MS);
|
setInterval(() => runRemoteSync(syncOrigin), REMOTE_SYNC_INTERVAL_MS);
|
||||||
|
|
||||||
// First gossip after 30s (let announcement propagate)
|
// First gossip after 30s (let announcement propagate)
|
||||||
setTimeout(runSwarmGossip, 30 * 1000);
|
if (publicSwarmEnabled) {
|
||||||
|
setTimeout(runSwarmGossip, 30 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
log('STARTUP', 'Background tasks running');
|
log('STARTUP', 'Background tasks running');
|
||||||
}, STARTUP_DELAY_MS);
|
}, STARTUP_DELAY_MS);
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import { db, nodes, users, posts } from '@/db';
|
|||||||
import { eq, sql } from 'drizzle-orm';
|
import { eq, sql } from 'drizzle-orm';
|
||||||
import type { SwarmAnnouncement, SwarmNodeInfo, SwarmCapability } from './types';
|
import type { SwarmAnnouncement, SwarmNodeInfo, SwarmCapability } from './types';
|
||||||
import { upsertSwarmNode, getSeedNodes, markNodeSuccess, markNodeFailure } from './registry';
|
import { upsertSwarmNode, getSeedNodes, markNodeSuccess, markNodeFailure } from './registry';
|
||||||
|
import { getPublicSwarmDomain, isPublicSwarmDomain } from './node-domain';
|
||||||
|
|
||||||
|
const PUBLIC_SWARM_DOMAIN_ERROR = 'Public swarm participation requires a real ICANN domain';
|
||||||
|
|
||||||
function normalizeOptionalUrl(value: string | null | undefined): string | undefined {
|
function normalizeOptionalUrl(value: string | null | undefined): string | undefined {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -77,6 +80,15 @@ export async function buildAnnouncement(): Promise<SwarmAnnouncement> {
|
|||||||
* SECURITY: Signs the announcement with the node's private key
|
* SECURITY: Signs the announcement with the node's private key
|
||||||
*/
|
*/
|
||||||
export async function announceToNode(targetDomain: string): Promise<{ success: boolean; error?: string }> {
|
export async function announceToNode(targetDomain: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!isPublicSwarmDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN)) {
|
||||||
|
return { success: false, error: PUBLIC_SWARM_DOMAIN_ERROR };
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicTargetDomain = getPublicSwarmDomain(targetDomain);
|
||||||
|
if (!publicTargetDomain) {
|
||||||
|
return { success: false, error: `Invalid public swarm domain: ${targetDomain}` };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const announcement = await buildAnnouncement();
|
const announcement = await buildAnnouncement();
|
||||||
|
|
||||||
@@ -90,7 +102,7 @@ export async function announceToNode(targetDomain: string): Promise<{ success: b
|
|||||||
signature,
|
signature,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseUrl = targetDomain.startsWith('http') ? targetDomain : `https://${targetDomain}`;
|
const baseUrl = `https://${publicTargetDomain}`;
|
||||||
const url = `${baseUrl}/api/swarm/announce`;
|
const url = `${baseUrl}/api/swarm/announce`;
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -104,20 +116,23 @@ export async function announceToNode(targetDomain: string): Promise<{ success: b
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.text();
|
const error = await response.text();
|
||||||
await markNodeFailure(targetDomain);
|
await markNodeFailure(publicTargetDomain);
|
||||||
return { success: false, error: `HTTP ${response.status}: ${error}` };
|
return { success: false, error: `HTTP ${response.status}: ${error}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// The remote node should respond with their info
|
// The remote node should respond with their info
|
||||||
const remoteInfo = await response.json() as SwarmNodeInfo;
|
const remoteInfo = await response.json() as SwarmNodeInfo;
|
||||||
|
if (getPublicSwarmDomain(remoteInfo.domain) !== publicTargetDomain) {
|
||||||
|
return { success: false, error: 'Remote node returned a different domain identity' };
|
||||||
|
}
|
||||||
|
|
||||||
// Add/update the remote node in our registry
|
// Add/update the remote node in our registry
|
||||||
await upsertSwarmNode(remoteInfo, 'direct');
|
await upsertSwarmNode(remoteInfo, 'direct');
|
||||||
await markNodeSuccess(targetDomain);
|
await markNodeSuccess(publicTargetDomain);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await markNodeFailure(targetDomain);
|
await markNodeFailure(publicTargetDomain);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
@@ -134,6 +149,10 @@ export async function announceToSeeds(): Promise<{
|
|||||||
}> {
|
}> {
|
||||||
const seeds = await getSeedNodes();
|
const seeds = await getSeedNodes();
|
||||||
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
||||||
|
|
||||||
|
if (!isPublicSwarmDomain(ourDomain)) {
|
||||||
|
return { successful: [], failed: [] };
|
||||||
|
}
|
||||||
|
|
||||||
// Don't announce to ourselves
|
// Don't announce to ourselves
|
||||||
const targetSeeds = seeds.filter(s => s !== ourDomain);
|
const targetSeeds = seeds.filter(s => s !== ourDomain);
|
||||||
@@ -157,8 +176,11 @@ export async function announceToSeeds(): Promise<{
|
|||||||
* Fetch node info from a remote node
|
* Fetch node info from a remote node
|
||||||
*/
|
*/
|
||||||
export async function fetchNodeInfo(domain: string): Promise<SwarmNodeInfo | null> {
|
export async function fetchNodeInfo(domain: string): Promise<SwarmNodeInfo | null> {
|
||||||
|
const publicDomain = getPublicSwarmDomain(domain);
|
||||||
|
if (!publicDomain) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const baseUrl = domain.startsWith('http') ? domain : `https://${domain}`;
|
const baseUrl = `https://${publicDomain}`;
|
||||||
|
|
||||||
// Try the swarm endpoint first
|
// Try the swarm endpoint first
|
||||||
let response = await fetch(`${baseUrl}/api/swarm/info`, {
|
let response = await fetch(`${baseUrl}/api/swarm/info`, {
|
||||||
@@ -178,8 +200,11 @@ export async function fetchNodeInfo(domain: string): Promise<SwarmNodeInfo | nul
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
const returnedDomain = getPublicSwarmDomain(data.domain || publicDomain);
|
||||||
|
if (returnedDomain !== publicDomain) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
domain: data.domain || domain,
|
domain: returnedDomain,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
logoUrl: data.logoUrl,
|
logoUrl: data.logoUrl,
|
||||||
@@ -203,13 +228,18 @@ export async function discoverNode(
|
|||||||
discoveredVia?: string
|
discoveredVia?: string
|
||||||
): Promise<{ success: boolean; isNew: boolean; error?: string }> {
|
): Promise<{ success: boolean; isNew: boolean; error?: string }> {
|
||||||
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
||||||
|
const publicDomain = getPublicSwarmDomain(domain);
|
||||||
|
|
||||||
|
if (!publicDomain) {
|
||||||
|
return { success: false, isNew: false, error: `Invalid public swarm domain: ${domain}` };
|
||||||
|
}
|
||||||
|
|
||||||
// Don't discover ourselves
|
// Don't discover ourselves
|
||||||
if (domain === ourDomain) {
|
if (publicDomain === getPublicSwarmDomain(ourDomain)) {
|
||||||
return { success: false, isNew: false, error: 'Cannot discover self' };
|
return { success: false, isNew: false, error: 'Cannot discover self' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = await fetchNodeInfo(domain);
|
const info = await fetchNodeInfo(publicDomain);
|
||||||
|
|
||||||
if (!info) {
|
if (!info) {
|
||||||
return { success: false, isNew: false, error: 'Could not fetch node info' };
|
return { success: false, isNew: false, error: 'Could not fetch node info' };
|
||||||
|
|||||||
+34
-14
@@ -20,6 +20,7 @@ import {
|
|||||||
} from './registry';
|
} from './registry';
|
||||||
import { upsertHandleEntries } from '@/lib/federation/handles';
|
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||||
import { buildAnnouncement } from './discovery';
|
import { buildAnnouncement } from './discovery';
|
||||||
|
import { getPublicSwarmDomain, isPublicSwarmDomain } from './node-domain';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a gossip payload to send to another node
|
* Build a gossip payload to send to another node
|
||||||
@@ -71,8 +72,8 @@ export async function buildGossipPayload(since?: string): Promise<SwarmGossipPay
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
sender: ourDomain,
|
sender: ourDomain,
|
||||||
nodes: [selfNode, ...nodes],
|
nodes: isPublicSwarmDomain(ourDomain) ? [selfNode, ...nodes] : nodes,
|
||||||
handles,
|
handles: handles.filter((handle) => isPublicSwarmDomain(handle.nodeDomain)),
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
since,
|
since,
|
||||||
};
|
};
|
||||||
@@ -91,8 +92,9 @@ export async function processGossip(
|
|||||||
|
|
||||||
// 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) {
|
const publicHandles = payload.handles?.filter((handle) => isPublicSwarmDomain(handle.nodeDomain)) ?? [];
|
||||||
handlesResult = await upsertHandleEntries(payload.handles);
|
if (publicHandles.length > 0) {
|
||||||
|
handlesResult = await upsertHandleEntries(publicHandles);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build our response with nodes/handles to share back
|
// Build our response with nodes/handles to share back
|
||||||
@@ -118,6 +120,19 @@ export async function gossipToNode(
|
|||||||
since?: string
|
since?: string
|
||||||
): Promise<SwarmSyncResult> {
|
): Promise<SwarmSyncResult> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
const publicTargetDomain = getPublicSwarmDomain(targetDomain);
|
||||||
|
|
||||||
|
if (!isPublicSwarmDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN) || !publicTargetDomain) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
nodesReceived: 0,
|
||||||
|
nodesSent: 0,
|
||||||
|
handlesReceived: 0,
|
||||||
|
handlesSent: 0,
|
||||||
|
error: 'Public swarm participation requires real ICANN domains',
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await buildGossipPayload(since);
|
const payload = await buildGossipPayload(since);
|
||||||
@@ -132,7 +147,7 @@ export async function gossipToNode(
|
|||||||
signature,
|
signature,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseUrl = targetDomain.startsWith('http') ? targetDomain : `https://${targetDomain}`;
|
const baseUrl = `https://${publicTargetDomain}`;
|
||||||
const url = `${baseUrl}/api/swarm/gossip`;
|
const url = `${baseUrl}/api/swarm/gossip`;
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -148,8 +163,8 @@ export async function gossipToNode(
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = `HTTP ${response.status}`;
|
const error = `HTTP ${response.status}`;
|
||||||
await markNodeFailure(targetDomain);
|
await markNodeFailure(publicTargetDomain);
|
||||||
await logSync(targetDomain, 'push', {
|
await logSync(publicTargetDomain, 'push', {
|
||||||
success: false,
|
success: false,
|
||||||
nodesReceived: 0,
|
nodesReceived: 0,
|
||||||
nodesSent: payload.nodes.length,
|
nodesSent: payload.nodes.length,
|
||||||
@@ -172,14 +187,15 @@ export async function gossipToNode(
|
|||||||
const gossipResponse = await response.json() as SwarmGossipResponse;
|
const gossipResponse = await response.json() as SwarmGossipResponse;
|
||||||
|
|
||||||
// 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, publicTargetDomain);
|
||||||
|
|
||||||
let handlesResult = { added: 0, updated: 0 };
|
let handlesResult = { added: 0, updated: 0 };
|
||||||
if (gossipResponse.handles && gossipResponse.handles.length > 0) {
|
const publicHandles = gossipResponse.handles?.filter((handle) => isPublicSwarmDomain(handle.nodeDomain)) ?? [];
|
||||||
handlesResult = await upsertHandleEntries(gossipResponse.handles);
|
if (publicHandles.length > 0) {
|
||||||
|
handlesResult = await upsertHandleEntries(publicHandles);
|
||||||
}
|
}
|
||||||
|
|
||||||
await markNodeSuccess(targetDomain);
|
await markNodeSuccess(publicTargetDomain);
|
||||||
|
|
||||||
const result: SwarmSyncResult = {
|
const result: SwarmSyncResult = {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -190,13 +206,13 @@ export async function gossipToNode(
|
|||||||
durationMs,
|
durationMs,
|
||||||
};
|
};
|
||||||
|
|
||||||
await logSync(targetDomain, 'push', result);
|
await logSync(publicTargetDomain, 'push', result);
|
||||||
return result;
|
return result;
|
||||||
} 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(publicTargetDomain);
|
||||||
|
|
||||||
const result: SwarmSyncResult = {
|
const result: SwarmSyncResult = {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -208,7 +224,7 @@ export async function gossipToNode(
|
|||||||
durationMs,
|
durationMs,
|
||||||
};
|
};
|
||||||
|
|
||||||
await logSync(targetDomain, 'push', result);
|
await logSync(publicTargetDomain, 'push', result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,6 +238,10 @@ export async function runGossipRound(): Promise<{
|
|||||||
totalNodesReceived: number;
|
totalNodesReceived: number;
|
||||||
totalHandlesReceived: number;
|
totalHandlesReceived: number;
|
||||||
}> {
|
}> {
|
||||||
|
if (!isPublicSwarmDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN)) {
|
||||||
|
return { contacted: 0, successful: 0, totalNodesReceived: 0, totalHandlesReceived: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { db, swarmNodes } from '@/db';
|
import { db, swarmNodes } from '@/db';
|
||||||
import { and, eq, inArray } from 'drizzle-orm';
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
|
import { normalizeNodeDomain } from './node-domain';
|
||||||
|
|
||||||
export function normalizeNodeDomain(domain: string): string {
|
export { normalizeNodeDomain } from './node-domain';
|
||||||
return domain
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/^https?:\/\//, '')
|
|
||||||
.replace(/\/.*$/, '')
|
|
||||||
.replace(/^@/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function isNodeBlocked(domain: string | null | undefined): Promise<boolean> {
|
export async function isNodeBlocked(domain: string | null | undefined): Promise<boolean> {
|
||||||
if (!db || !domain) return false;
|
if (!db || !domain) return false;
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { getPublicSwarmDomain, isPublicSwarmDomain } from './node-domain';
|
||||||
|
|
||||||
|
describe('public swarm domains', () => {
|
||||||
|
it.each([
|
||||||
|
['synapsis.social', 'synapsis.social'],
|
||||||
|
['https://Node.Synapsis.Social/', 'node.synapsis.social'],
|
||||||
|
['node.synapsis.social:8443', 'node.synapsis.social:8443'],
|
||||||
|
['social.example.co.uk', 'social.example.co.uk'],
|
||||||
|
])('accepts a public ICANN domain: %s', (input, expected) => {
|
||||||
|
expect(getPublicSwarmDomain(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'localhost',
|
||||||
|
'localhost:43821',
|
||||||
|
'127.0.0.1',
|
||||||
|
'192.168.1.20:43821',
|
||||||
|
'[::1]:43821',
|
||||||
|
'synapsis.local',
|
||||||
|
'synapsis.test',
|
||||||
|
'synapsis.invalid',
|
||||||
|
'synapsis.internal',
|
||||||
|
'example.com',
|
||||||
|
'node.example.com',
|
||||||
|
'node.onion',
|
||||||
|
'home.arpa',
|
||||||
|
'not a domain',
|
||||||
|
])('rejects a local, reserved, or non-public domain: %s', (input) => {
|
||||||
|
expect(isPublicSwarmDomain(input)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { parse } from 'tldts';
|
||||||
|
|
||||||
|
export function normalizeNodeDomain(domain: string): string {
|
||||||
|
return domain
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^https?:\/\//, '')
|
||||||
|
.replace(/\/.*$/, '')
|
||||||
|
.replace(/^@/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the canonical host for a publicly addressable swarm node.
|
||||||
|
*
|
||||||
|
* The swarm is a public network, so local development hosts, IP addresses,
|
||||||
|
* special-use names, and made-up TLDs must never enter the node registry.
|
||||||
|
* Ports are retained because a public hostname may intentionally expose
|
||||||
|
* Synapsis on a non-standard port.
|
||||||
|
*/
|
||||||
|
export function getPublicSwarmDomain(value: string | null | undefined): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const normalized = normalizeNodeDomain(value);
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(`https://${normalized}`);
|
||||||
|
if (url.username || url.password) return null;
|
||||||
|
|
||||||
|
const hostname = url.hostname.toLowerCase().replace(/\.$/, '');
|
||||||
|
const result = parse(hostname, {
|
||||||
|
allowPrivateDomains: false,
|
||||||
|
detectSpecialUse: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
!result.domain ||
|
||||||
|
result.isIcann !== true ||
|
||||||
|
result.isIp ||
|
||||||
|
result.isSpecialUse
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.port ? `${hostname}:${url.port}` : hostname;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPublicSwarmDomain(value: string | null | undefined): boolean {
|
||||||
|
return getPublicSwarmDomain(value) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
+28
-17
@@ -8,7 +8,7 @@ import { db, swarmNodes, swarmSeeds, swarmSyncLog } from '@/db';
|
|||||||
import { eq, desc, and, gt, lt, sql } from 'drizzle-orm';
|
import { eq, desc, and, gt, lt, sql } from 'drizzle-orm';
|
||||||
import type { SwarmNodeInfo, SwarmCapability, SwarmSyncResult } from './types';
|
import type { SwarmNodeInfo, SwarmCapability, SwarmSyncResult } from './types';
|
||||||
import { SWARM_CONFIG, DEFAULT_SEED_NODES } from './types';
|
import { SWARM_CONFIG, DEFAULT_SEED_NODES } from './types';
|
||||||
import { normalizeNodeDomain } from './node-blocklist';
|
import { getPublicSwarmDomain, isPublicSwarmDomain } from './node-domain';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create a swarm node entry
|
* Get or create a swarm node entry
|
||||||
@@ -21,11 +21,14 @@ export async function upsertSwarmNode(
|
|||||||
return { isNew: false };
|
return { isNew: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await db.query.swarmNodes.findFirst({
|
const normalizedDomain = getPublicSwarmDomain(node.domain);
|
||||||
where: { domain: normalizeNodeDomain(node.domain) },
|
if (!normalizedDomain) {
|
||||||
});
|
throw new Error(`Swarm nodes must use a public ICANN domain: ${node.domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedDomain = normalizeNodeDomain(node.domain);
|
const existing = await db.query.swarmNodes.findFirst({
|
||||||
|
where: { domain: normalizedDomain },
|
||||||
|
});
|
||||||
|
|
||||||
const capabilities = node.capabilities ? JSON.stringify(node.capabilities) : null;
|
const capabilities = node.capabilities ? JSON.stringify(node.capabilities) : null;
|
||||||
|
|
||||||
@@ -86,8 +89,10 @@ export async function upsertSwarmNodes(
|
|||||||
// Filter out our own domain
|
// Filter out our own domain
|
||||||
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
||||||
const filteredNodes = nodes.filter(n => n.domain !== ourDomain);
|
const filteredNodes = nodes.filter(n => n.domain !== ourDomain);
|
||||||
const normalizedOurDomain = ourDomain ? normalizeNodeDomain(ourDomain) : null;
|
const normalizedOurDomain = getPublicSwarmDomain(ourDomain);
|
||||||
const safeNodes = filteredNodes.filter(n => normalizeNodeDomain(n.domain) !== normalizedOurDomain);
|
const safeNodes = filteredNodes.filter(n =>
|
||||||
|
isPublicSwarmDomain(n.domain) && getPublicSwarmDomain(n.domain) !== normalizedOurDomain
|
||||||
|
);
|
||||||
|
|
||||||
for (const node of safeNodes) {
|
for (const node of safeNodes) {
|
||||||
const result = await upsertSwarmNode(node, discoveredVia);
|
const result = await upsertSwarmNode(node, discoveredVia);
|
||||||
@@ -115,7 +120,7 @@ export async function getActiveSwarmNodes(limit = 100): Promise<SwarmNodeInfo[]>
|
|||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
return nodes.map(nodeToInfo);
|
return nodes.filter((node) => isPublicSwarmDomain(node.domain)).map(nodeToInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,7 +138,7 @@ export async function getNodesForGossip(count: number): Promise<SwarmNodeInfo[]>
|
|||||||
limit: count,
|
limit: count,
|
||||||
});
|
});
|
||||||
|
|
||||||
return nodes.map(nodeToInfo);
|
return nodes.filter((node) => isPublicSwarmDomain(node.domain)).map(nodeToInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,7 +155,7 @@ export async function getNodesSince(since: Date, limit = 100): Promise<SwarmNode
|
|||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
return nodes.map(nodeToInfo);
|
return nodes.filter((node) => isPublicSwarmDomain(node.domain)).map(nodeToInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -268,11 +273,11 @@ export async function getSeedNodes(): Promise<string[]> {
|
|||||||
orderBy: (swarmSeeds) => [swarmSeeds.priority],
|
orderBy: (swarmSeeds) => [swarmSeeds.priority],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (seeds.length === 0) {
|
const publicSeeds = seeds.map(s => s.domain).filter(isPublicSwarmDomain);
|
||||||
return [...DEFAULT_SEED_NODES];
|
|
||||||
}
|
|
||||||
|
|
||||||
return seeds.map(s => s.domain);
|
return publicSeeds.length > 0
|
||||||
|
? publicSeeds
|
||||||
|
: DEFAULT_SEED_NODES.filter(isPublicSwarmDomain);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -281,8 +286,13 @@ export async function getSeedNodes(): Promise<string[]> {
|
|||||||
export async function addSeedNode(domain: string, priority = 100): Promise<void> {
|
export async function addSeedNode(domain: string, priority = 100): Promise<void> {
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
|
|
||||||
|
const normalizedDomain = getPublicSwarmDomain(domain);
|
||||||
|
if (!normalizedDomain) {
|
||||||
|
throw new Error(`Seed nodes must use a public ICANN domain: ${domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
await db.insert(swarmSeeds)
|
await db.insert(swarmSeeds)
|
||||||
.values({ domain, priority })
|
.values({ domain: normalizedDomain, priority })
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: swarmSeeds.domain,
|
target: swarmSeeds.domain,
|
||||||
set: { priority, isEnabled: true },
|
set: { priority, isEnabled: true },
|
||||||
@@ -303,13 +313,14 @@ export async function getSwarmStats() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allNodes = await db.query.swarmNodes.findMany();
|
const allNodes = await db.query.swarmNodes.findMany();
|
||||||
const activeNodes = allNodes.filter(n => n.isActive);
|
const publicNodes = allNodes.filter(n => isPublicSwarmDomain(n.domain));
|
||||||
|
const activeNodes = publicNodes.filter(n => n.isActive);
|
||||||
|
|
||||||
const totalUsers = activeNodes.reduce((sum, n) => sum + (n.userCount || 0), 0);
|
const totalUsers = activeNodes.reduce((sum, n) => sum + (n.userCount || 0), 0);
|
||||||
const totalPosts = activeNodes.reduce((sum, n) => sum + (n.postCount || 0), 0);
|
const totalPosts = activeNodes.reduce((sum, n) => sum + (n.postCount || 0), 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalNodes: allNodes.length,
|
totalNodes: publicNodes.length,
|
||||||
activeNodes: activeNodes.length,
|
activeNodes: activeNodes.length,
|
||||||
totalUsers,
|
totalUsers,
|
||||||
totalPosts,
|
totalPosts,
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import crypto from 'crypto';
|
|||||||
import { db, users } from '@/db';
|
import { db, users } from '@/db';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { canonicalize } from '@/lib/crypto/user-signing';
|
import { canonicalize } from '@/lib/crypto/user-signing';
|
||||||
import { isNodeBlocked, normalizeNodeDomain } from './node-blocklist';
|
import { isNodeBlocked } from './node-blocklist';
|
||||||
|
import { getPublicSwarmDomain } from './node-domain';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign a payload with the node's private key
|
* Sign a payload with the node's private key
|
||||||
@@ -57,15 +58,18 @@ export function verifySignature(payload: any, signature: string, publicKey: stri
|
|||||||
*/
|
*/
|
||||||
export async function getNodePublicKey(domain: string): Promise<string | null> {
|
export async function getNodePublicKey(domain: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const normalizedDomain = normalizeNodeDomain(domain);
|
const normalizedDomain = getPublicSwarmDomain(domain);
|
||||||
|
if (!normalizedDomain) {
|
||||||
|
console.warn(`[Signature] Refusing public key fetch for non-public node ${domain}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (await isNodeBlocked(normalizedDomain)) {
|
if (await isNodeBlocked(normalizedDomain)) {
|
||||||
console.warn(`[Signature] Refusing public key fetch for blocked node ${normalizedDomain}`);
|
console.warn(`[Signature] Refusing public key fetch for blocked node ${normalizedDomain}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have a cached node info
|
// Check if we have a cached node info
|
||||||
const protocol = normalizedDomain.includes('localhost') ? 'http' : 'https';
|
const response = await fetch(`https://${normalizedDomain}/api/node`, {
|
||||||
const response = await fetch(`${protocol}://${normalizedDomain}/api/node`, {
|
|
||||||
headers: { 'Accept': 'application/json' },
|
headers: { 'Accept': 'application/json' },
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
});
|
});
|
||||||
@@ -96,7 +100,11 @@ export async function verifySwarmRequest(
|
|||||||
signature: string,
|
signature: string,
|
||||||
senderDomain: string
|
senderDomain: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const normalizedDomain = normalizeNodeDomain(senderDomain);
|
const normalizedDomain = getPublicSwarmDomain(senderDomain);
|
||||||
|
if (!normalizedDomain) {
|
||||||
|
console.warn(`[Signature] Rejected non-public swarm node ${senderDomain}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (await isNodeBlocked(normalizedDomain)) {
|
if (await isNodeBlocked(normalizedDomain)) {
|
||||||
console.warn(`[Signature] Rejected blocked node ${normalizedDomain}`);
|
console.warn(`[Signature] Rejected blocked node ${normalizedDomain}`);
|
||||||
return false;
|
return false;
|
||||||
@@ -132,7 +140,11 @@ export async function verifyUserInteraction(
|
|||||||
userDomain: string
|
userDomain: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const normalizedDomain = normalizeNodeDomain(userDomain);
|
const normalizedDomain = getPublicSwarmDomain(userDomain);
|
||||||
|
if (!normalizedDomain) {
|
||||||
|
console.warn(`[Signature] Rejected user interaction from non-public node ${userDomain}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (await isNodeBlocked(normalizedDomain)) {
|
if (await isNodeBlocked(normalizedDomain)) {
|
||||||
console.warn(`[Signature] Rejected user interaction from blocked node ${normalizedDomain}`);
|
console.warn(`[Signature] Rejected user interaction from blocked node ${normalizedDomain}`);
|
||||||
return false;
|
return false;
|
||||||
@@ -150,8 +162,7 @@ export async function verifyUserInteraction(
|
|||||||
publicKey = user.publicKey;
|
publicKey = user.publicKey;
|
||||||
} else {
|
} else {
|
||||||
// Fetch from remote node
|
// Fetch from remote node
|
||||||
const protocol = normalizedDomain.includes('localhost') ? 'http' : 'https';
|
const response = await fetch(`https://${normalizedDomain}/api/users/${userHandle}`, {
|
||||||
const response = await fetch(`${protocol}://${normalizedDomain}/api/users/${userHandle}`, {
|
|
||||||
headers: { 'Accept': 'application/json' },
|
headers: { 'Accept': 'application/json' },
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user