diff --git a/.changeset/peering-sync.md b/.changeset/peering-sync.md
new file mode 100644
index 000000000..1ed8bcd49
--- /dev/null
+++ b/.changeset/peering-sync.md
@@ -0,0 +1,25 @@
+---
+"nostream": minor
+---
+
+feat(peering): sync configured peers over NIP-77 and a live feed
+
+Adds an outbound peering-sync worker. For every enabled `peering.peers`
+entry it reads the peer's NIP-11 `peering.scopes`, authenticates with NIP-42
+using the relay's own keypair, reconciles each scope over NIP-77 negentropy,
+fetches only the missing ids, and keeps a live subscription for the same
+scopes. Periodic reconciliation and reconnect-and-resync close gaps the live
+feed can miss, and a new or restarted peer converges without a full dump.
+
+Fetched and live events are accepted only when they match the agreed scope,
+were requested by id, and pass id/signature verification. They are applied with
+the same kind rules as local writes (addressable `d`-tag keys, kind-5 deletes,
+kind-62 vanishes, ephemeral broadcast-only), and authors under an active vanish
+request are skipped. Live events are broadcast to local subscribers; backfill is
+not, so a full sync does not fan out a dump. Wide scopes are split by kind and
+time, overflowed fetch batches are retried in smaller pieces, live re-checks use
+the scope bounds rather than the subscription overlap, and fetch/live buffers are
+bounded by count and bytes. Per-peer sync state (status, last reconciliation,
+lag, missing count) is heartbeat-refreshed during long reconciliations, recorded
+in Redis, exposed at `GET /admin/peering`, shown in the admin console's
+Operations view, and included in diagnostics exports.
diff --git a/CONFIGURATION.md b/CONFIGURATION.md
index c7567ec71..132daea27 100644
--- a/CONFIGURATION.md
+++ b/CONFIGURATION.md
@@ -348,3 +348,10 @@ terms, or contact details are exposed.
only when a NIP-42 AUTH event proves one of those pubkeys; matching a URL or
self-declaring a key is not enough. The relay's own identity, key storage, and
the AUTH exchange are documented in [docs/PEERING.md](docs/PEERING.md).
+
+Each enabled `peering.peers` entry is also the outbound sync list: a single
+peering-sync worker reads that peer's NIP-11 `peering.scopes`, authenticates
+with NIP-42, reconciles history over NIP-77, and keeps a live subscription to
+those scopes. No extra feed configuration is required. Per-peer sync state is
+available at `GET /admin/peering` and in the admin console's Operations view.
+The peer must advertise NIP-77 and have it enabled.
diff --git a/docs/PEERING.md b/docs/PEERING.md
index e8cb9122c..1d242a75e 100644
--- a/docs/PEERING.md
+++ b/docs/PEERING.md
@@ -5,8 +5,8 @@ side must be able to prove which relay it is: a URL is an address, not an
identity. Nostream uses NIP-42 AUTH signed with the relay's own keypair for that
proof, and a configured pubkey list for trust.
-This phase covers identity only. Feed treatment, sync, on-demand requests,
-payments, and the admin console build on it.
+This document covers relay identity, peer-aware feed treatment, and outbound
+sync. On-demand requests, payments, and the full admin console build on it.
## Relay keypair
@@ -81,6 +81,55 @@ authenticated for the caller to use for a feed or negentropy session. This
proves our identity to the peer; it does not verify the peer's identity in
return.
+## Outbound sync
+
+Sync is driven by the peer's advertisement, not by a second config-file feed
+list. For every enabled `peering.peers` entry the relay starts one peering-sync
+worker session:
+
+1. Read the peer's NIP-11 document and its `peering.scopes`. The document's
+ `self` must match the configured peer pubkey when it is present, and the
+ peer must advertise NIP-77. URL literals and resolved DNS addresses that are
+ loopback, private, or link-local are refused, including on redirects.
+2. Authenticate with NIP-42 using this relay's keypair.
+3. Open the live `REQ` first, starting from a short overlap so events published
+ while history is syncing are not lost. A rolling `windowSeconds` is checked
+ against the current time, and never widened past its own cutoff.
+4. Reconcile each advertised scope with a NIP-77 negentropy session. A scope is
+ split into filters by kind range and, if the peer still rejects it as too
+ large, by time range. Only the ids the peer has and we lack are fetched, in
+ `REQ` batches, so an empty peer converges without a full dump.
+5. Reconcile again every 15 minutes. Any disconnect reconnects and reruns the
+ reconciliation, which closes any gap the live feed missed.
+
+Fetched and live events are accepted only when they match the scope, were
+requested by id, and pass id and signature verification. They are then applied
+with the same kind rules as locally published events: addressable events keep
+their `d` tag as the deduplication key, kind-5 deletes hide their targets, and
+kind-62 requests delete the author's events and set the vanish flag only when
+their relay tags target this relay. Ephemeral events are broadcast live but
+never stored. Authors under an active vanish request are skipped, with the
+vanish check cached once per author per batch. Live events are also broadcast to
+local subscribers, so a peer feed is indistinguishable from a locally published
+event to this relay's clients. Backfilled history is not broadcast, because it
+predates any live subscription.
+
+If the peer rejects a filter as too large, the worker splits it by kind and then
+by time range and retries. A fetch batch that exceeds the byte budget is split
+into smaller batches without dropping the connection. The live queue and fetch
+buffers are bounded by both count and bytes, and a failing live event is logged
+without stalling the rest of the queue. The per-peer state heartbeat keeps a long
+reconciliation from being reported as stale.
+
+Each reconciliation writes the peer's state to Redis and exposes it at
+`GET /admin/peering`: status, last reconciliation, latest event age ("lag"),
+and the number of events the last reconciliation found missing. The admin
+console shows the same rows under Operations → Peering.
+
+NIP-77 must be enabled on the source relay (`nip77.enabled` and the PostgreSQL
+event store). `peering.peers` on the receiving relay is the only trust material
+an operator supplies.
+
## Feed treatment
Once a connection has proven a configured peer pubkey, its subscription and
@@ -106,8 +155,10 @@ message limits come from `limits.peer`, not `limits.client` or `limits.message`:
limits. An unauthenticated or unconfigured connection stays on the client
limits exactly as before.
-Scope enforcement (only the agreed kinds and time ranges) is part of the sync
-work; this phase grants feed-sized limits to trusted peers.
+Scope enforcement (only the agreed kinds and time ranges) is applied on the
+outbound sync path: the sync worker reconciles only the peer's advertised
+scopes and drops fetched events that fall outside them. Inbound feed scope
+enforcement remains part of the sync work that is still in progress.
Only the public half of the keypair is ever visible on the wire. Inbound AUTH is
verified by `AuthMessageHandler`: event id, signature, timestamp, challenge, and
diff --git a/resources/admin/assets/admin-ops.js b/resources/admin/assets/admin-ops.js
index db08005cb..6a86563ab 100644
--- a/resources/admin/assets/admin-ops.js
+++ b/resources/admin/assets/admin-ops.js
@@ -10,6 +10,8 @@
const elements = {
workersSync: document.getElementById('ops-workers-sync'),
workersBody,
+ peeringSync: document.getElementById('peering-sync'),
+ peeringBody: document.getElementById('peering-body'),
nip05Error: document.getElementById('ops-nip05-error'),
nip05Domain: document.getElementById('ops-nip05-domain'),
nip05Verified: document.getElementById('ops-nip05-verified'),
@@ -100,8 +102,71 @@
.join('')
}
- const renderNip05 = (rows, pagination) => {
+ const formatLag = (seconds) => {
+ if (seconds === undefined || seconds === null) {
+ return '—'
+ }
+ if (seconds < 60) {
+ return `${Math.max(0, Math.round(seconds))}s`
+ }
+ if (seconds < 3600) {
+ return `${Math.round(seconds / 60)}m`
+ }
+ if (seconds < 86400) {
+ return `${Math.round(seconds / 3600)}h`
+ }
+ return `${Math.round(seconds / 86400)}d`
+ }
+
+ const peeringSignalBadge = (row) => {
+ if (!row.configured || !row.enabled) {
+ return 'Disabled'
+ }
+ if (row.state?.status === 'error') {
+ return 'Error'
+ }
+ if (row.stale) {
+ return 'Stale'
+ }
+
+ switch (row.state?.status) {
+ case 'live':
+ return signalBadge(false)
+ case 'connecting':
+ case 'reconciling':
+ return 'Syncing'
+ case 'stopped':
+ return 'Stopped'
+ default:
+ return 'Pending'
+ }
+ }
+
+ const renderPeering = (rows) => {
if (!Array.isArray(rows) || !rows.length) {
+ elements.peeringBody.innerHTML =
+ '
| No peering peers configured. |
'
+ return
+ }
+
+ elements.peeringBody.innerHTML = rows
+ .map((row) => {
+ const state = row.state
+ const status = state?.status ? String(state.status) : 'no sync yet'
+ const title = [row.relayUrl, row.pubkey, status].filter(Boolean).join(' · ')
+ return `
+ ${escapeHtml(shortPubkey(row.pubkey))} |
+ ${escapeHtml(row.relayUrl)} |
+ ${escapeHtml(formatTime(state?.lastReconciledAt))} |
+ ${escapeHtml(formatLag(state?.lagSeconds))} |
+ ${escapeHtml(state?.missingEvents ?? '—')} |
+ ${peeringSignalBadge(row)} |
+
`
+ })
+ .join('')
+ }
+
+ const renderNip05 = (rows, pagination) => { if (!Array.isArray(rows) || !rows.length) {
elements.nip05Body.innerHTML = '| No matching verifications. |
'
} else {
elements.nip05Body.innerHTML = rows
@@ -204,6 +269,18 @@
}
}
+ const loadPeering = async () => {
+ const response = await adminFetch('/peering')
+ if (!response.ok) {
+ throw new Error('Failed to load peering state')
+ }
+ const payload = await response.json()
+ renderPeering(payload.peers)
+ if (elements.peeringSync) {
+ elements.peeringSync.innerHTML = `> peers: updated ${formatTime(new Date().toISOString())}`
+ }
+ }
+
const buildNip05Query = () => {
const params = new URLSearchParams()
params.set('limit', String(nip05Limit))
@@ -284,9 +361,16 @@
}
const refreshAll = async () => {
- try {
- await Promise.all([loadWorkers(), loadNip05()])
- } catch {
+ const results = await Promise.allSettled([loadWorkers(), loadPeering(), loadNip05()])
+
+ if (results[1]?.status === 'rejected') {
+ if (elements.peeringSync) {
+ elements.peeringSync.innerHTML =
+ '> peers: unable to refresh sync state'
+ }
+ }
+
+ if (results[0]?.status === 'rejected' || results[2]?.status === 'rejected') {
showNip05Error('Unable to refresh operations data.')
}
}
diff --git a/resources/admin/dashboard.html b/resources/admin/dashboard.html
index ef046457c..4631f0147 100644
--- a/resources/admin/dashboard.html
+++ b/resources/admin/dashboard.html
@@ -226,6 +226,29 @@ Worker ops
+
+ Peering
+ > peers: not loaded
+
+
+ Configured peering peers and their sync state
+
+
+ | Peer |
+ Relay |
+ Last reconciliation |
+ Lag |
+ Missing |
+ Signal |
+
+
+
+ | No peering peers configured. |
+
+
+
+
+
Tor / I2P & network probes
diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml
index f53357362..888b28737 100755
--- a/resources/default-settings.yaml
+++ b/resources/default-settings.yaml
@@ -198,7 +198,9 @@ peering:
terms: []
# Peering peers trusted by their relay pubkey (hex or npub), never by URL
# alone. A connection that proves one of these keys with NIP-42 AUTH is
- # recognized as a peer; any other key gets no peer recognition.
+ # recognized as a peer; any other key gets no peer recognition. Enabled
+ # peers are also synced: the peering-sync worker reads each peer's NIP-11
+ # peering scopes, reconciles them over NIP-77, and keeps a live feed.
# Example:
# peers:
# - pubkey: replace-with-peer-relay-pubkey-in-hex
diff --git a/src/@types/peering.ts b/src/@types/peering.ts
new file mode 100644
index 000000000..fecb40cf2
--- /dev/null
+++ b/src/@types/peering.ts
@@ -0,0 +1,40 @@
+import { PeeringScope, PeeringTerm } from './settings'
+
+/** One scope advertised by a peer in its NIP-11 `peering` section. */
+export type AdvertisedPeeringScope = PeeringScope
+
+export interface PeeringAdvertisement {
+ enabled: boolean
+ scopes: AdvertisedPeeringScope[]
+ terms: PeeringTerm[]
+ /** The relay key the peer advertises as `self`, when it publishes one. */
+ self?: string
+ supportedNips: number[]
+}
+
+export type PeeringSyncStatus = 'idle' | 'connecting' | 'reconciling' | 'live' | 'error' | 'stopped'
+
+/**
+ * Per-peer sync state. Written by the peering-sync worker and read by the admin
+ * console; `lagSeconds` is how old the newest event we hold in a current scope
+ * is, so a healthy live feed stays near zero.
+ */
+export interface PeeringPeerSyncState {
+ pubkey: string
+ relayUrl: string
+ status: PeeringSyncStatus
+ scopeCount: number
+ updatedAt: string
+ lastSyncStartedAt?: string
+ lastSyncFinishedAt?: string
+ lastReconciledAt?: string
+ lastEventAt?: string
+ /** Newest `created_at` held in a scope that is still current (no past `until`). */
+ latestEventCreatedAt?: number
+ lagSeconds?: number
+ /** Events the last reconciliation found on the peer but not locally. */
+ missingEvents?: number
+ /** Events fetched from the peer during the last reconciliation. */
+ receivedEvents?: number
+ lastError?: string
+}
diff --git a/src/app/app.ts b/src/app/app.ts
index c11cb7939..e0da3e483 100644
--- a/src/app/app.ts
+++ b/src/app/app.ts
@@ -1,23 +1,21 @@
+import { Serializable } from 'child_process'
import { Cluster, Worker } from 'cluster'
+import { FSWatcher } from 'fs'
import { cpus, hostname } from 'os'
import { levels } from 'pino'
import { path, pathEq } from 'ramda'
-import { FSWatcher } from 'fs'
-
-import { addOnion } from '../tor/client'
-import { createLogger } from '../factories/logger-factory'
-import { EventStoreBackend } from '../constants/base'
-import { IRunnable } from '../@types/base'
import packageJson from '../../package.json'
-import { Serializable } from 'child_process'
-import { Settings } from '../@types/settings'
-import { SettingsStatic } from '../utils/settings'
-import { BANNER_WIDTH, centre, isStructuredLogFormat, RELAY_BANNER_LINES } from '../utils/banner'
-import { shutdownMetricsTelemetry } from '../telemetry/metrics'
+import { IRunnable } from '../@types/base'
import { OperatorNotificationEventType } from '../@types/operator-notifications'
+import { Settings } from '../@types/settings'
+import { EventStoreBackend } from '../constants/base'
+import { createLogger } from '../factories/logger-factory'
import { RedisRelayBroadcastFanout } from '../relay-broadcast/redis-relay-broadcast-fanout'
-import { enqueueOperatorNotification } from '../utils/operator-notification-enqueue'
+import { shutdownMetricsTelemetry } from '../telemetry/metrics'
+import { addOnion } from '../tor/client'
+import { BANNER_WIDTH, centre, isStructuredLogFormat, RELAY_BANNER_LINES } from '../utils/banner'
import { EVENT_STORE_BACKEND_ENV, getConfiguredEventStoreBackend } from '../utils/event-store'
+import { enqueueOperatorNotification } from '../utils/operator-notification-enqueue'
import { RelayBroadcastDeduplicator } from '../utils/relay-broadcast-deduplicator'
import {
isRelayBroadcastFanoutEnabled,
@@ -25,6 +23,7 @@ import {
RelayBroadcastMessage,
} from '../utils/relay-broadcast-message'
import { setRelayBroadcastFanoutReady } from '../utils/relay-broadcast-state'
+import { SettingsStatic } from '../utils/settings'
import { getPrimaryShutdownDeadlineMs } from '../utils/shutdown-state'
const logger = createLogger('app-primary')
@@ -113,22 +112,24 @@ export class App implements IRunnable {
// own so a pipeline reads this line like every other one.
if (structuredLogs) {
const dvmWorkerCount = Array.isArray(settings?.dvm?.workers) ? settings.dvm.workers.length : 0
- this.process.stdout.write(`${JSON.stringify({
- level: levels.values.info,
- time: Date.now(),
- pid: process.pid,
- hostname: hostname(),
- msg: 'relay starting',
- version: packageJson.version,
- supportedNips: packageJson.supportedNips,
- payments: paymentsEnabled ? 'enabled' : 'disabled',
- paymentsProvider: paymentsEnabled ? path(['payments', 'processor'], settings) : undefined,
- eventStore: eventStoreBackend,
- clientWorkers: workerCount,
- maintenanceWorkers: 1,
- relayMonitor: settings.nip66?.enabled === true,
- dvmWorkers: dvmWorkerCount,
- })}\n`)
+ this.process.stdout.write(
+ `${JSON.stringify({
+ level: levels.values.info,
+ time: Date.now(),
+ pid: process.pid,
+ hostname: hostname(),
+ msg: 'relay starting',
+ version: packageJson.version,
+ supportedNips: packageJson.supportedNips,
+ payments: paymentsEnabled ? 'enabled' : 'disabled',
+ paymentsProvider: paymentsEnabled ? path(['payments', 'processor'], settings) : undefined,
+ eventStore: eventStoreBackend,
+ clientWorkers: workerCount,
+ maintenanceWorkers: 1,
+ relayMonitor: settings.nip66?.enabled === true,
+ dvmWorkers: dvmWorkerCount,
+ })}\n`,
+ )
}
const createWorker = (env: Record
) => {
@@ -158,6 +159,14 @@ export class App implements IRunnable {
logCentered('mirroring.static is no longer supported (ignored)')
}
+ const peeringPeers = settings.peering?.peers ?? []
+ if (peeringPeers.length > 0) {
+ createWorker({
+ WORKER_TYPE: 'peering-sync',
+ })
+ logCentered(`1 peering-sync worker started (${peeringPeers.length} peer(s) configured)`)
+ }
+
if (settings.nip66?.enabled) {
createWorker({
WORKER_TYPE: 'relay-monitor',
diff --git a/src/app/peering-sync-worker.ts b/src/app/peering-sync-worker.ts
new file mode 100644
index 000000000..fee9253b0
--- /dev/null
+++ b/src/app/peering-sync-worker.ts
@@ -0,0 +1,811 @@
+import { FSWatcher } from 'fs'
+import { z } from 'zod'
+import { ICacheAdapter } from '../@types/adapters'
+import { IRunnable } from '../@types/base'
+import { Event } from '../@types/event'
+import { PeeringPeerSyncState } from '../@types/peering'
+import { IEventRepository, IUserRepository } from '../@types/repositories'
+import { PeeringPeer, PeeringScope, Settings } from '../@types/settings'
+import { SubscriptionFilter } from '../@types/subscription'
+import { createLogger } from '../factories/logger-factory'
+import { idSchema, pubkeySchema, signatureSchema, tagSchema } from '../schemas/base-schema'
+import { createEventBatchPersister } from '../services/event-import-service'
+import { ADMIN_OPS_HEARTBEAT_KEEPALIVE_MS, publishAdminOpsWorkerHeartbeatSafely } from '../utils/admin-ops-store'
+import {
+ broadcastEvent,
+ getRelayPrivateKey,
+ isEphemeralEvent,
+ isEventIdValid,
+ isEventMatchingFilter,
+ isEventSignatureValid,
+ isExpiredEvent,
+ isRequestToVanishEvent,
+} from '../utils/event'
+import { delayMs } from '../utils/misc'
+import { FetchPeeringAdvertisementOptions, fetchPeeringAdvertisement } from '../utils/peering-advertisement'
+import {
+ AuthenticatedPeerConnection,
+ authenticateToPeeringRelay,
+ listEnabledPeeringPeers,
+ parsePeeringPeerPubkey,
+} from '../utils/peering-auth'
+import { PeeringConnection, PeeringFetchOverflowError, PeeringSyncConnection } from '../utils/peering-connection'
+import {
+ isCurrentPeeringScope,
+ peeringScopesToLiveFilters,
+ peeringScopeToFilters,
+ splitPeeringFilter,
+} from '../utils/peering-scope'
+import { PeeringSyncStateStore } from '../utils/peering-sync-state'
+import { SettingsStatic } from '../utils/settings'
+
+const logger = createLogger('peering-sync-worker')
+
+export const PEERING_SYNC_RECONCILE_INTERVAL_MS = 15 * 60_000
+export const PEERING_SYNC_RECONNECT_DELAY_MS = 5_000
+export const PEERING_SYNC_EVENT_BATCH_SIZE = 500
+export const PEERING_SYNC_PEER_REFRESH_MS = 30_000
+export const PEERING_SYNC_STATE_HEARTBEAT_MS = 60_000
+export const PEERING_SYNC_MAX_PENDING_EVENTS = 500
+export const PEERING_SYNC_MAX_PENDING_EVENT_BYTES = 8 * 1024 * 1024
+export const PEERING_SYNC_MAX_SCOPE_SPLIT_DEPTH = 20
+
+const eventShapeSchema = z
+ .object({
+ id: idSchema,
+ pubkey: pubkeySchema,
+ created_at: z.number().int().nonnegative(),
+ kind: z.number().int(),
+ tags: z.array(tagSchema),
+ content: z.string(),
+ sig: signatureSchema,
+ })
+ .strict()
+
+type AuthenticatePeerFn = (options: {
+ relayUrl: string
+ privateKey: string
+ timeoutMs?: number
+}) => Promise
+
+type FetchAdvertisementFn = (
+ relayUrl: string,
+ options?: FetchPeeringAdvertisementOptions,
+) => ReturnType
+
+export interface PeeringSyncWorkerOptions {
+ authenticate?: AuthenticatePeerFn
+ fetchAdvertisement?: FetchAdvertisementFn
+ createConnection?: (socket: AuthenticatedPeerConnection['socket']) => PeeringSyncConnection
+ stateStore?: PeeringSyncStateStore
+ opsCache?: ICacheAdapter
+ userRepository?: Pick
+ privateKey?: () => string
+ reconnectDelayMs?: number
+ reconcileIntervalMs?: number
+ peerRefreshMs?: number
+ stateHeartbeatMs?: number
+ eventBatchSize?: number
+}
+
+interface SessionCallbacks {
+ onReconciled: (error?: Error) => void
+}
+
+const normalizePeer = (peer: PeeringPeer): PeeringPeer | undefined => {
+ const pubkey = parsePeeringPeerPubkey(peer.pubkey)
+ if (!pubkey || peer.enabled === false) {
+ return undefined
+ }
+
+ return { ...peer, pubkey }
+}
+
+const newestCreatedAt = (items: { event_created_at: number }[]): number | undefined =>
+ items.length ? items[items.length - 1]!.event_created_at : undefined
+
+const isOversizedReconciliationError = (error: unknown): boolean => {
+ const message = error instanceof Error ? error.message : String(error)
+
+ return /too big|too many events|exceeds .*events|maximum .*events/i.test(message)
+}
+
+class PeeringPeerSession {
+ private stopped = false
+ private connection: PeeringSyncConnection | undefined
+ private reconcileTimer: NodeJS.Timeout | undefined
+ private reconcileRunning = false
+ private liveFilters: SubscriptionFilter[] = []
+ private scopes: PeeringScope[] = []
+ private state: PeeringPeerSyncState
+ private stateTimer: NodeJS.Timeout | undefined
+ private stateDirty = false
+ private readonly pendingEvents: Event[] = []
+ private pendingEventBytes = 0
+ private drainingEvents = false
+ private readonly persistEvents: (events: Event[]) => Promise
+ private stateHeartbeatTimer: NodeJS.Timeout | undefined
+
+ public constructor(
+ private readonly peer: PeeringPeer,
+ private readonly settings: () => Settings,
+ private readonly eventRepository: IEventRepository,
+ private readonly options: Required<
+ Pick
+ > &
+ Pick<
+ PeeringSyncWorkerOptions,
+ | 'stateStore'
+ | 'reconnectDelayMs'
+ | 'reconcileIntervalMs'
+ | 'eventBatchSize'
+ | 'userRepository'
+ | 'stateHeartbeatMs'
+ >,
+ private readonly callbacks: SessionCallbacks,
+ ) {
+ this.state = {
+ pubkey: peer.pubkey.toLowerCase(),
+ relayUrl: peer.relayUrl,
+ status: 'idle',
+ scopeCount: 0,
+ updatedAt: new Date().toISOString(),
+ }
+ this.persistEvents = createEventBatchPersister(eventRepository, {
+ userRepository: options.userRepository,
+ relayUrl: settings().info?.relay_url,
+ })
+ }
+
+ public start(): void {
+ this.stateHeartbeatTimer = setInterval(
+ () => this.publishStateHeartbeat(),
+ this.options.stateHeartbeatMs ?? PEERING_SYNC_STATE_HEARTBEAT_MS,
+ )
+ this.stateHeartbeatTimer.unref()
+ void this.runLoop()
+ }
+
+ public get id(): string {
+ return `${this.peer.pubkey}|${this.peer.relayUrl}`
+ }
+
+ public stop(): void {
+ if (this.stopped) {
+ return
+ }
+
+ this.stopped = true
+ clearInterval(this.reconcileTimer)
+ clearInterval(this.stateHeartbeatTimer)
+ this.stateHeartbeatTimer = undefined
+ if (this.stateTimer) {
+ clearTimeout(this.stateTimer)
+ this.stateTimer = undefined
+ }
+ this.connection?.close()
+ this.connection = undefined
+ this.pendingEvents.length = 0
+ this.pendingEventBytes = 0
+ this.queueState({ status: 'stopped' })
+ void this.flushState()
+ }
+
+ private async runLoop(): Promise {
+ const reconnectDelayMs = this.options.reconnectDelayMs ?? PEERING_SYNC_RECONNECT_DELAY_MS
+
+ while (!this.stopped) {
+ if (!this.isStillConfigured()) {
+ logger('peer %s is no longer enabled in settings; stopping its session', this.peer.relayUrl)
+ return
+ }
+
+ try {
+ await this.connectAndFollow()
+ } catch (error) {
+ if (this.stopped) {
+ break
+ }
+
+ const message = error instanceof Error ? error.message : String(error)
+ logger.error('peer %s sync failed: %s', this.peer.relayUrl, message)
+ this.queueState({ status: 'error', lastError: message })
+ this.callbacks.onReconciled(error instanceof Error ? error : new Error(message))
+ } finally {
+ this.connection?.close()
+ this.connection = undefined
+ clearInterval(this.reconcileTimer)
+ this.reconcileTimer = undefined
+ }
+
+ if (!this.stopped) {
+ await delayMs(reconnectDelayMs)
+ }
+ }
+ }
+
+ private isStillConfigured(): boolean {
+ const normalized = parsePeeringPeerPubkey(this.peer.pubkey)
+
+ return listEnabledPeeringPeers(this.settings()).some(
+ (peer) => parsePeeringPeerPubkey(peer.pubkey) === normalized && peer.relayUrl === this.peer.relayUrl,
+ )
+ }
+
+ private async connectAndFollow(): Promise {
+ this.queueState({ status: 'connecting', lastError: undefined })
+ const advertisement = await this.options.fetchAdvertisement(this.peer.relayUrl)
+ if (!advertisement.enabled) {
+ throw new Error('peer does not advertise peering')
+ }
+
+ if (!advertisement.supportedNips.includes(77)) {
+ throw new Error('peer does not advertise NIP-77')
+ }
+
+ const configuredPubkey = parsePeeringPeerPubkey(this.peer.pubkey)
+ if (configuredPubkey && advertisement.self && advertisement.self !== configuredPubkey) {
+ throw new Error(`peer advertises relay key ${advertisement.self} but is configured as ${configuredPubkey}`)
+ }
+
+ this.queueState({ scopeCount: advertisement.scopes.length })
+
+ const authenticated = await this.options.authenticate({
+ relayUrl: this.peer.relayUrl,
+ privateKey: this.options.privateKey(),
+ })
+
+ if (this.stopped) {
+ authenticated.socket.close()
+ return
+ }
+
+ const connection = this.options.createConnection(authenticated.socket)
+ this.connection = connection
+ this.scopes = advertisement.scopes
+
+ // Start the live feed first: a long history reconciliation can outlast the
+ // overlap window, and events published after its snapshot must still be
+ // delivered. Reconciliation and live writes are both duplicate-safe.
+ this.liveFilters = peeringScopesToLiveFilters(advertisement.scopes, Math.floor(Date.now() / 1000))
+ if (this.liveFilters.length) {
+ await connection.subscribe(this.liveFilters, (event) => this.queueEvent(event))
+ }
+
+ if (this.stopped) {
+ return
+ }
+
+ await this.reconcile(connection, advertisement.scopes)
+
+ if (this.stopped) {
+ return
+ }
+
+ this.queueState({ status: 'live' })
+ this.startReconcileTimer(connection, advertisement.scopes)
+
+ await connection.waitForClose()
+
+ if (!this.stopped) {
+ throw new Error('peer connection closed')
+ }
+ }
+
+ private startReconcileTimer(connection: PeeringSyncConnection, scopes: PeeringScope[]): void {
+ clearInterval(this.reconcileTimer)
+ const intervalMs = this.options.reconcileIntervalMs ?? PEERING_SYNC_RECONCILE_INTERVAL_MS
+
+ this.reconcileTimer = setInterval(() => {
+ void this.reconcile(connection, scopes).catch((error) => {
+ const message = error instanceof Error ? error.message : String(error)
+ logger.error('periodic reconciliation with %s failed: %s', this.peer.relayUrl, message)
+ this.queueState({ status: 'error', lastError: message })
+ this.callbacks.onReconciled(error instanceof Error ? error : new Error(message))
+ })
+ }, intervalMs)
+ this.reconcileTimer.unref()
+ }
+
+ private async reconcile(connection: PeeringSyncConnection, scopes: PeeringScope[]): Promise {
+ if (this.reconcileRunning || this.stopped) {
+ return
+ }
+
+ this.reconcileRunning = true
+ const startedAt = new Date().toISOString()
+ const startedAtSeconds = Math.floor(Date.now() / 1000)
+ this.queueState({
+ status: 'reconciling',
+ lastSyncStartedAt: startedAt,
+ })
+
+ try {
+ const needed = new Map()
+ const currentFilters: SubscriptionFilter[] = []
+ let newestCurrent: number | undefined
+
+ for (const scope of scopes) {
+ if (this.stopped) {
+ return
+ }
+
+ const filters = peeringScopeToFilters(scope, startedAtSeconds)
+ const countCurrent = isCurrentPeeringScope(scope, startedAtSeconds)
+ if (countCurrent) {
+ currentFilters.push(...filters)
+ }
+
+ for (const filter of filters) {
+ newestCurrent = await this.reconcileFilter(
+ connection,
+ filter,
+ startedAtSeconds,
+ needed,
+ newestCurrent,
+ countCurrent,
+ )
+ }
+ }
+
+ const ids = [...needed.keys()]
+ const batchSize = this.options.eventBatchSize ?? PEERING_SYNC_EVENT_BATCH_SIZE
+ let received = 0
+
+ for (let offset = 0; offset < ids.length; offset += batchSize) {
+ if (this.stopped) {
+ return
+ }
+
+ const batch = ids.slice(offset, offset + batchSize)
+ const result = await this.fetchAndStoreBatch(connection, batch, needed)
+
+ received += result.stored
+ for (const event of result.events) {
+ if (currentFilters.some((filter) => isEventMatchingFilter(filter)(event))) {
+ newestCurrent = newestCurrent === undefined ? event.created_at : Math.max(newestCurrent, event.created_at)
+ }
+ }
+ }
+
+ const finishedAt = new Date().toISOString()
+ const finishedAtSeconds = Math.floor(Date.now() / 1000)
+ this.queueState({
+ status: 'live',
+ lastSyncFinishedAt: finishedAt,
+ lastReconciledAt: finishedAt,
+ lastError: undefined,
+ missingEvents: ids.length,
+ receivedEvents: received,
+ ...(newestCurrent !== undefined ? { latestEventCreatedAt: newestCurrent } : {}),
+ ...(newestCurrent !== undefined ? { lagSeconds: Math.max(0, finishedAtSeconds - newestCurrent) } : {}),
+ })
+ this.callbacks.onReconciled()
+ logger(
+ 'reconciled %s over %d scope(s): %d missing, %d received',
+ this.peer.relayUrl,
+ scopes.length,
+ ids.length,
+ received,
+ )
+ } finally {
+ this.reconcileRunning = false
+ }
+ }
+
+ private async reconcileFilter(
+ connection: PeeringSyncConnection,
+ filter: SubscriptionFilter,
+ nowSeconds: number,
+ needed: Map,
+ newestCurrent: number | undefined,
+ countCurrent: boolean,
+ depth = 0,
+ ): Promise {
+ if (this.stopped) {
+ return newestCurrent
+ }
+
+ const items = await this.eventRepository.findReconciliationItems(filter)
+ const newest = newestCreatedAt(items)
+
+ if (countCurrent && newest !== undefined) {
+ newestCurrent = newestCurrent === undefined ? newest : Math.max(newestCurrent, newest)
+ }
+
+ try {
+ const result = await connection.reconcile(filter, items)
+ for (const id of result.needIds) {
+ if (!needed.has(id)) {
+ needed.set(id, filter)
+ }
+ }
+
+ return newestCurrent
+ } catch (error) {
+ if (depth >= PEERING_SYNC_MAX_SCOPE_SPLIT_DEPTH || !isOversizedReconciliationError(error)) {
+ throw error
+ }
+
+ const split = splitPeeringFilter(filter, nowSeconds)
+ if (!split) {
+ throw error
+ }
+
+ logger.warn(
+ 'peer %s rejected a filter as too large; splitting it in time (depth %d)',
+ this.peer.relayUrl,
+ depth + 1,
+ )
+
+ let current = newestCurrent
+ for (const child of split) {
+ current = await this.reconcileFilter(connection, child, nowSeconds, needed, current, countCurrent, depth + 1)
+ }
+
+ return current
+ }
+ }
+
+ private queueEvent(event: Event): void {
+ const size = Buffer.byteLength(JSON.stringify(event))
+ if (
+ this.pendingEvents.length >= PEERING_SYNC_MAX_PENDING_EVENTS ||
+ this.pendingEventBytes + size > PEERING_SYNC_MAX_PENDING_EVENT_BYTES
+ ) {
+ logger.warn('peer %s live event backlog exceeded; reconnecting', this.peer.relayUrl)
+ this.connection?.close()
+ return
+ }
+
+ this.pendingEvents.push(event)
+ this.pendingEventBytes += size
+ void this.drainEvents()
+ }
+
+ private async drainEvents(): Promise {
+ if (this.drainingEvents) {
+ return
+ }
+
+ this.drainingEvents = true
+ try {
+ while (this.pendingEvents.length > 0 && !this.stopped) {
+ const event = this.pendingEvents.shift() as Event
+ this.pendingEventBytes = Math.max(0, this.pendingEventBytes - Buffer.byteLength(JSON.stringify(event)))
+
+ try {
+ await this.applyLiveEvent(event)
+ } catch (error) {
+ // One bad event must not poison the queue or become an unhandled
+ // rejection; the next reconciliation can retry it.
+ logger.error('failed to apply live event from %s: %o', this.peer.relayUrl, error)
+ }
+ }
+ } finally {
+ this.drainingEvents = false
+ }
+ }
+
+ private async applyLiveEvent(event: Event): Promise {
+ const nowSeconds = Math.floor(Date.now() / 1000)
+ // Persistence uses the scope's own bounds, not the subscription overlap:
+ // a late publish older than 60s is still valid for a scope without a
+ // rolling window.
+ const currentFilters = this.scopes
+ .filter((scope) => isCurrentPeeringScope(scope, nowSeconds))
+ .flatMap((scope) => peeringScopeToFilters(scope, nowSeconds))
+
+ if (!currentFilters.some((filter) => isEventMatchingFilter(filter)(event))) {
+ return
+ }
+
+ await this.acceptEvents([event], undefined, true)
+ const latestCreatedAt = Math.max(this.state.latestEventCreatedAt ?? 0, event.created_at)
+
+ this.queueState({
+ lastEventAt: new Date().toISOString(),
+ latestEventCreatedAt: latestCreatedAt,
+ lagSeconds: Math.max(0, nowSeconds - latestCreatedAt),
+ })
+ }
+
+ private async fetchAndStoreBatch(
+ connection: PeeringSyncConnection,
+ ids: string[],
+ needed: Map,
+ ): Promise<{ stored: number; events: Event[] }> {
+ try {
+ const events = await connection.fetchEvents(ids)
+ const accepted = await this.acceptEvents(events, needed)
+
+ return { stored: accepted.stored, events: accepted.events }
+ } catch (error) {
+ if (!(error instanceof PeeringFetchOverflowError)) {
+ throw error
+ }
+
+ if (ids.length <= 1) {
+ logger.warn(
+ 'peer %s sent an oversized event for %s; leaving it for the next reconciliation',
+ this.peer.relayUrl,
+ ids[0],
+ )
+ return { stored: 0, events: [] }
+ }
+
+ const midpoint = Math.ceil(ids.length / 2)
+ const left = await this.fetchAndStoreBatch(connection, ids.slice(0, midpoint), needed)
+ const right = await this.fetchAndStoreBatch(connection, ids.slice(midpoint), needed)
+
+ return {
+ stored: left.stored + right.stored,
+ events: [...left.events, ...right.events],
+ }
+ }
+ }
+
+ /**
+ * Accepts only events that were asked for and still match the scope, then
+ * verifies the event id and signature. A peer cannot inject an arbitrary
+ * event through a reconciliation reply, and a compromised transport cannot
+ * alter one that the peer did sign.
+ */
+ private async acceptEvents(
+ events: Event[],
+ scopeById?: Map,
+ broadcast = false,
+ ): Promise<{ events: Event[]; stored: number }> {
+ const accepted: Event[] = []
+
+ for (const event of events) {
+ if (scopeById && !scopeById.has(event.id)) {
+ continue
+ }
+
+ if (!eventShapeSchema.safeParse(event).success) {
+ continue
+ }
+
+ const scopeFilter = scopeById?.get(event.id)
+ if (scopeFilter && !isEventMatchingFilter(scopeFilter)(event)) {
+ continue
+ }
+
+ if (isExpiredEvent(event)) {
+ continue
+ }
+
+ if (!(await isEventIdValid(event)) || !(await isEventSignatureValid(event))) {
+ logger.warn('peer %s sent an event with an invalid id or signature: %s', this.peer.relayUrl, event.id)
+ continue
+ }
+
+ accepted.push(event)
+ }
+
+ if (accepted.length === 0) {
+ return { events: [], stored: 0 }
+ }
+
+ let stored = 0
+
+ if (broadcast) {
+ // Live traffic is one event at a time, so we can tell which write
+ // actually stored or changed something. Only those are rebroadcast:
+ // an overlap replay of an event both relays already hold must not echo.
+ for (const event of accepted) {
+ const count = await this.persistEvents([event])
+ stored += count
+
+ if ((count > 0 || isEphemeralEvent(event)) && !isRequestToVanishEvent(event)) {
+ void broadcastEvent(event).catch((error) => {
+ logger.error('failed to broadcast synced event %s: %o', event.id, error)
+ })
+ }
+ }
+
+ return { events: accepted, stored }
+ }
+
+ // Backfill is deliberately not broadcast: those events predate any live
+ // subscription and a full sync must not fan out a dump.
+ stored = await this.persistEvents(accepted)
+
+ return { events: accepted, stored }
+ }
+
+ private queueState(partial: Partial): void {
+ if (this.stopped && partial.status !== 'stopped') {
+ return
+ }
+
+ this.state = {
+ ...this.state,
+ ...partial,
+ updatedAt: new Date().toISOString(),
+ }
+ this.stateDirty = true
+
+ if (this.stateTimer) {
+ return
+ }
+
+ this.stateTimer = setTimeout(() => {
+ this.stateTimer = undefined
+ void this.flushState()
+ }, 250)
+ this.stateTimer.unref()
+ }
+
+ private async flushState(): Promise {
+ if (!this.options.stateStore || !this.stateDirty) {
+ return
+ }
+
+ this.stateDirty = false
+ try {
+ await this.options.stateStore.publish(this.state)
+ } catch (error) {
+ logger.warn('failed to record sync state for %s: %o', this.peer.relayUrl, error)
+ }
+ }
+
+ /** Keeps a long reconciliation from looking stale in the admin console. */
+ private publishStateHeartbeat(): void {
+ if (this.stopped || !this.options.stateStore) {
+ return
+ }
+
+ const nowSeconds = Math.floor(Date.now() / 1000)
+ this.state = {
+ ...this.state,
+ updatedAt: new Date().toISOString(),
+ ...(this.state.latestEventCreatedAt !== undefined
+ ? { lagSeconds: Math.max(0, nowSeconds - this.state.latestEventCreatedAt) }
+ : {}),
+ }
+ this.stateDirty = true
+ void this.flushState()
+ }
+}
+
+export class PeeringSyncWorker implements IRunnable {
+ private sessions = new Map()
+ private heartbeatTimer: NodeJS.Timeout | undefined
+ private peerRefreshTimer: NodeJS.Timeout | undefined
+ private watchers: FSWatcher[] | undefined
+ private lastRunFinishedAt: string | undefined
+ private lastError: string | undefined
+ private readonly startedAt = new Date().toISOString()
+
+ public constructor(
+ private readonly process: NodeJS.Process,
+ private readonly settings: () => Settings,
+ private readonly eventRepository: IEventRepository,
+ private readonly options: PeeringSyncWorkerOptions = {},
+ ) {
+ this.process
+ .on('SIGINT', this.onExit.bind(this))
+ .on('SIGHUP', this.onExit.bind(this))
+ .on('SIGTERM', this.onExit.bind(this))
+ .on('uncaughtException', this.onError.bind(this))
+ .on('unhandledRejection', this.onError.bind(this))
+ }
+
+ public run(): void {
+ this.watchers = SettingsStatic.watchSettings()
+ this.applyConfiguredPeers()
+
+ this.publishHeartbeat()
+ this.heartbeatTimer = setInterval(() => this.publishHeartbeat(), ADMIN_OPS_HEARTBEAT_KEEPALIVE_MS)
+ this.heartbeatTimer.unref()
+
+ // `watchSettings` invalidates the cached settings document; this periodic
+ // pass also replaces a session when only its relay URL changed, which a
+ // pubkey-only configuration check would miss.
+ this.peerRefreshTimer = setInterval(
+ () => this.applyConfiguredPeers(),
+ this.options.peerRefreshMs ?? PEERING_SYNC_PEER_REFRESH_MS,
+ )
+ this.peerRefreshTimer.unref()
+ }
+
+ private sessionKey(peer: PeeringPeer): string {
+ return `${parsePeeringPeerPubkey(peer.pubkey) ?? peer.pubkey}|${peer.relayUrl}`
+ }
+
+ private applyConfiguredPeers(): void {
+ const peers = listEnabledPeeringPeers(this.settings())
+ .map(normalizePeer)
+ .filter((peer): peer is PeeringPeer => peer !== undefined)
+ const desired = new Map(peers.map((peer) => [this.sessionKey(peer), peer]))
+
+ for (const [id, session] of this.sessions) {
+ if (!desired.has(id)) {
+ logger('stopping sync with removed or changed peer %s', session.id)
+ session.stop()
+ this.sessions.delete(id)
+ }
+ }
+
+ const commonOptions = {
+ authenticate: this.options.authenticate ?? authenticateToPeeringRelay,
+ fetchAdvertisement: this.options.fetchAdvertisement ?? fetchPeeringAdvertisement,
+ createConnection: this.options.createConnection ?? ((socket) => new PeeringConnection(socket)),
+ privateKey: this.options.privateKey ?? (() => getRelayPrivateKey(this.settings().info?.relay_url)),
+ stateStore: this.options.stateStore,
+ userRepository: this.options.userRepository,
+ reconnectDelayMs: this.options.reconnectDelayMs,
+ reconcileIntervalMs: this.options.reconcileIntervalMs,
+ eventBatchSize: this.options.eventBatchSize,
+ stateHeartbeatMs: this.options.stateHeartbeatMs,
+ }
+
+ for (const [id, peer] of desired) {
+ if (this.sessions.has(id)) {
+ continue
+ }
+
+ const session = new PeeringPeerSession(peer, this.settings, this.eventRepository, commonOptions, {
+ onReconciled: (error) => {
+ if (error) {
+ this.lastError = error.message
+ return
+ }
+ this.lastRunFinishedAt = new Date().toISOString()
+ this.lastError = undefined
+ },
+ })
+
+ logger('starting sync with peer %s', peer.relayUrl)
+ this.sessions.set(id, session)
+ session.start()
+ }
+
+ if (this.sessions.size === 0) {
+ logger('no enabled peering peers configured; worker idle')
+ }
+ }
+
+ private publishHeartbeat(): void {
+ void publishAdminOpsWorkerHeartbeatSafely(this.options.opsCache, {
+ type: 'peering-sync',
+ updatedAt: new Date().toISOString(),
+ lastRunStartedAt: this.startedAt,
+ lastRunFinishedAt: this.lastRunFinishedAt,
+ lastError: this.lastError,
+ })
+ }
+
+ private onError(error: Error): void {
+ logger('error: %o', error)
+ this.lastError = error.message
+ }
+
+ private onExit(): void {
+ logger('exiting')
+ this.close(() => {
+ this.process.exit(0)
+ })
+ }
+
+ public close(callback?: () => void): void {
+ logger('closing')
+ clearInterval(this.heartbeatTimer)
+ this.heartbeatTimer = undefined
+ clearInterval(this.peerRefreshTimer)
+ this.peerRefreshTimer = undefined
+ if (Array.isArray(this.watchers)) {
+ for (const watcher of this.watchers) {
+ watcher.close()
+ }
+ }
+ this.watchers = undefined
+ for (const session of this.sessions.values()) {
+ session.stop()
+ }
+ this.sessions.clear()
+ callback?.()
+ }
+}
diff --git a/src/controllers/admin/get-admin-peering-controller.ts b/src/controllers/admin/get-admin-peering-controller.ts
new file mode 100644
index 000000000..3ae4d470e
--- /dev/null
+++ b/src/controllers/admin/get-admin-peering-controller.ts
@@ -0,0 +1,21 @@
+import { Request, Response } from 'express'
+
+import { IController } from '../../@types/controllers'
+import { Settings } from '../../@types/settings'
+import { mergePeeringRows } from '../../utils/admin-peering'
+import { PeeringSyncStateStore } from '../../utils/peering-sync-state'
+import { loadMergedSettings } from '../../utils/settings-config'
+
+export class GetAdminPeeringController implements IController {
+ public constructor(
+ private readonly stateStore: PeeringSyncStateStore,
+ private readonly getSettings: () => Settings = loadMergedSettings,
+ ) {}
+
+ public async handleRequest(_request: Request, response: Response): Promise {
+ const [states, settings] = await Promise.all([this.stateStore.list(), this.getSettings()])
+ const peers = mergePeeringRows(settings, states)
+
+ response.status(200).setHeader('content-type', 'application/json').send({ peers })
+ }
+}
diff --git a/src/factories/controllers/get-admin-peering-controller-factory.ts b/src/factories/controllers/get-admin-peering-controller-factory.ts
new file mode 100644
index 000000000..bfc2bd47f
--- /dev/null
+++ b/src/factories/controllers/get-admin-peering-controller-factory.ts
@@ -0,0 +1,19 @@
+import { IController } from '../../@types/controllers'
+import { RedisAdapter } from '../../adapters/redis-adapter'
+import { getCacheClient } from '../../cache/client'
+import { GetAdminPeeringController } from '../../controllers/admin/get-admin-peering-controller'
+import { PeeringSyncStateStore } from '../../utils/peering-sync-state'
+
+export const createGetAdminPeeringController = (): IController => {
+ // The RedisAdapter constructor attaches connection listeners. Reusing one
+ // adapter keeps repeated dashboard polls from stacking listeners on the
+ // shared cache client.
+ if (!sharedController) {
+ const stateStore = new PeeringSyncStateStore(new RedisAdapter(getCacheClient()))
+ sharedController = new GetAdminPeeringController(stateStore)
+ }
+
+ return sharedController
+}
+
+let sharedController: IController | undefined
diff --git a/src/factories/peering-sync-worker-factory.ts b/src/factories/peering-sync-worker-factory.ts
new file mode 100644
index 000000000..a28f7771f
--- /dev/null
+++ b/src/factories/peering-sync-worker-factory.ts
@@ -0,0 +1,19 @@
+import { PeeringSyncWorker } from '../app/peering-sync-worker'
+import { getMasterDbClient, getReadReplicaDbClient } from '../database/client'
+import { UserRepository } from '../repositories/user-repository'
+import { PeeringSyncStateStore } from '../utils/peering-sync-state'
+import { createAdminOpsCacheAdapter } from './admin-ops-cache-adapter-factory'
+import { createEventRepository } from './event-repository-factory'
+import { createSettings } from './settings-factory'
+
+export const peeringSyncWorkerFactory = () => {
+ const cache = createAdminOpsCacheAdapter()
+ const dbClient = getMasterDbClient()
+ const eventRepository = createEventRepository(dbClient, getReadReplicaDbClient())
+ const userRepository = new UserRepository(dbClient, eventRepository)
+
+ return new PeeringSyncWorker(process, createSettings, eventRepository, {
+ ...(cache ? { opsCache: cache, stateStore: new PeeringSyncStateStore(cache) } : {}),
+ userRepository,
+ })
+}
diff --git a/src/index.ts b/src/index.ts
index b909447ca..87b86a66f 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -3,6 +3,7 @@ import cluster from 'cluster'
import { appFactory } from './factories/app-factory'
import { dvmOrchestratorWorkerFactory } from './factories/dvm-orchestrator-worker-factory'
import { maintenanceWorkerFactory } from './factories/maintenance-worker-factory'
+import { peeringSyncWorkerFactory } from './factories/peering-sync-worker-factory'
import { relayMonitorWorkerFactory } from './factories/relay-monitor-worker-factory'
import { workerFactory } from './factories/worker-factory'
import { initializeMetricsTelemetry } from './telemetry/metrics'
@@ -16,6 +17,8 @@ export const getRunner = () => {
return workerFactory()
case 'maintenance':
return maintenanceWorkerFactory()
+ case 'peering-sync':
+ return peeringSyncWorkerFactory()
case 'relay-monitor':
return relayMonitorWorkerFactory()
case 'dvm-orchestrator':
diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts
index 8dd5317a5..58590d8e9 100644
--- a/src/routes/admin/index.ts
+++ b/src/routes/admin/index.ts
@@ -1,33 +1,33 @@
import express, { json, Router } from 'express'
-
-import { createGetAdminNotificationDeliveryLogController } from '../../factories/controllers/get-admin-notification-delivery-log-controller-factory'
-import { createGetAdminNotificationsController } from '../../factories/controllers/get-admin-notifications-controller-factory'
-import { createPatchAdminNotificationsController } from '../../factories/controllers/patch-admin-notifications-controller-factory'
+import { createGetAdminAuditLogController } from '../../factories/controllers/get-admin-audit-log-controller-factory'
+import { createGetAdminDiagnosticsExportController } from '../../factories/controllers/get-admin-diagnostics-export-controller-factory'
import { createGetAdminHealthController } from '../../factories/controllers/get-admin-health-controller-factory'
import { createGetAdminMetricsController } from '../../factories/controllers/get-admin-metrics-controller-factory'
+import { createGetAdminModerationLimitsController } from '../../factories/controllers/get-admin-moderation-limits-controller-factory'
+import { createGetAdminModerationStatsController } from '../../factories/controllers/get-admin-moderation-stats-controller-factory'
import { createGetAdminNetworkHealthController } from '../../factories/controllers/get-admin-network-health-controller-factory'
import { createGetAdminNetworkHealthHistoryController } from '../../factories/controllers/get-admin-network-health-history-controller-factory'
+import { createGetAdminNotificationDeliveryLogController } from '../../factories/controllers/get-admin-notification-delivery-log-controller-factory'
+import { createGetAdminNotificationsController } from '../../factories/controllers/get-admin-notifications-controller-factory'
+import { createGetAdminOpsNip05VerificationsController } from '../../factories/controllers/get-admin-ops-nip05-verifications-controller-factory'
+import { createGetAdminOpsStorageCountsController } from '../../factories/controllers/get-admin-ops-storage-counts-controller-factory'
+import { createGetAdminOpsWorkersController } from '../../factories/controllers/get-admin-ops-workers-controller-factory'
+import { createGetAdminPeeringController } from '../../factories/controllers/get-admin-peering-controller-factory'
import { createGetAdminSessionController } from '../../factories/controllers/get-admin-session-controller-factory'
import { createGetAdminSettingsBackupsController } from '../../factories/controllers/get-admin-settings-backups-controller-factory'
import { createGetAdminSettingsController } from '../../factories/controllers/get-admin-settings-controller-factory'
import { createGetAdminSettingsSchemaController } from '../../factories/controllers/get-admin-settings-schema-controller-factory'
+import { createPatchAdminModerationLimitsController } from '../../factories/controllers/patch-admin-moderation-limits-controller-factory'
+import { createPatchAdminNotificationsController } from '../../factories/controllers/patch-admin-notifications-controller-factory'
import { createPatchAdminSettingsController } from '../../factories/controllers/patch-admin-settings-controller-factory'
-import { createPostAdminNotificationTestController } from '../../factories/controllers/post-admin-notification-test-controller-factory'
import { createPostAdminLoginController } from '../../factories/controllers/post-admin-login-controller-factory'
import { createPostAdminLogoutController } from '../../factories/controllers/post-admin-logout-controller-factory'
+import { createPostAdminModerationActionsController } from '../../factories/controllers/post-admin-moderation-actions-controller-factory'
+import { createPostAdminNetworkHealthRunController } from '../../factories/controllers/post-admin-network-health-run-controller-factory'
+import { createPostAdminNotificationTestController } from '../../factories/controllers/post-admin-notification-test-controller-factory'
+import { createPostAdminOpsNip05ReverifyController } from '../../factories/controllers/post-admin-ops-nip05-reverify-controller-factory'
import { createPostAdminSettingsRestoreController } from '../../factories/controllers/post-admin-settings-restore-controller-factory'
import { createPostAdminSettingsValidateController } from '../../factories/controllers/post-admin-settings-validate-controller-factory'
-import { createGetAdminOpsWorkersController } from '../../factories/controllers/get-admin-ops-workers-controller-factory'
-import { createGetAdminOpsNip05VerificationsController } from '../../factories/controllers/get-admin-ops-nip05-verifications-controller-factory'
-import { createGetAdminOpsStorageCountsController } from '../../factories/controllers/get-admin-ops-storage-counts-controller-factory'
-import { createPostAdminOpsNip05ReverifyController } from '../../factories/controllers/post-admin-ops-nip05-reverify-controller-factory'
-import { createGetAdminAuditLogController } from '../../factories/controllers/get-admin-audit-log-controller-factory'
-import { createGetAdminDiagnosticsExportController } from '../../factories/controllers/get-admin-diagnostics-export-controller-factory'
-import { createPostAdminNetworkHealthRunController } from '../../factories/controllers/post-admin-network-health-run-controller-factory'
-import { createGetAdminModerationLimitsController } from '../../factories/controllers/get-admin-moderation-limits-controller-factory'
-import { createGetAdminModerationStatsController } from '../../factories/controllers/get-admin-moderation-stats-controller-factory'
-import { createPatchAdminModerationLimitsController } from '../../factories/controllers/patch-admin-moderation-limits-controller-factory'
-import { createPostAdminModerationActionsController } from '../../factories/controllers/post-admin-moderation-actions-controller-factory'
import { adminAuthGateMiddleware, adminAuthMiddleware } from '../../handlers/request-handlers/admin-auth-middleware'
import { adminEnabledMiddleware } from '../../handlers/request-handlers/admin-enabled-middleware'
import { adminJsonBodyMiddleware } from '../../handlers/request-handlers/admin-json-body-middleware'
@@ -206,6 +206,12 @@ router.get(
adminAuthMiddleware,
withAdminController(createGetAdminOpsWorkersController),
)
+router.get(
+ '/peering',
+ adminRateLimitMiddleware,
+ adminAuthMiddleware,
+ withAdminController(createGetAdminPeeringController),
+)
router.get(
'/ops/nip05-verifications',
adminRateLimitMiddleware,
diff --git a/src/services/event-import-service.ts b/src/services/event-import-service.ts
index 460ac4de9..7f498c0dc 100644
--- a/src/services/event-import-service.ts
+++ b/src/services/event-import-service.ts
@@ -6,6 +6,11 @@ const streamArrayModule = require('stream-json/streamers/stream-array.js') as {
}
const streamArray = streamArrayModule.default
+import { Event } from '../@types/event'
+import { IEventRepository, IUserRepository } from '../@types/repositories'
+
+import { EventDeduplicationMetadataKey, EventExpirationTimeMetadataKey, EventKinds, EventTags } from '../constants/base'
+import { eventSchema } from '../schemas/event-schema'
import {
getEventExpiration,
isDeleteEvent,
@@ -14,14 +19,10 @@ import {
isEventSignatureValid,
isParameterizedReplaceableEvent,
isReplaceableEvent,
+ isRequestToVanishEvent,
} from '../utils/event'
import { attemptValidation } from '../utils/validation'
-import { EventDeduplicationMetadataKey, EventExpirationTimeMetadataKey, EventTags } from '../constants/base'
-import { Event } from '../@types/event'
-import { eventSchema } from '../schemas/event-schema'
-import { IEventRepository } from '../@types/repositories'
-
type EventWithImportMetadata = Event & {
[EventDeduplicationMetadataKey]?: string[]
[EventExpirationTimeMetadataKey]?: number
@@ -88,8 +89,19 @@ const isDestroyableStream = (
return typeof candidate.destroy === 'function'
}
+export interface EventBatchPersisterOptions {
+ /**
+ * When present, events from vanished authors are skipped and a kind-62
+ * request applies the same deletion and vanish-state rules as the live
+ * event path. Import leaves this out and keeps its historical behavior.
+ */
+ userRepository?: Pick
+ /** This relay's URL, used to honor kind-62 `r` tag targeting. */
+ relayUrl?: string
+}
+
export const createEventBatchPersister =
- (eventRepository: IEventRepository) =>
+ (eventRepository: IEventRepository, options: EventBatchPersisterOptions = {}) =>
async (events: Event[]): Promise => {
if (!events.length) {
return 0
@@ -99,16 +111,51 @@ export const createEventBatchPersister =
const regularEvents: Event[] = []
const replaceableEvents: Event[] = []
+ // A batch often carries many events from the same author. Ask once per
+ // author instead of once per event.
+ const vanishedByAuthor = new Map>()
+ const isVanished = (pubkey: string): Promise => {
+ if (!options.userRepository) {
+ return Promise.resolve(false)
+ }
+
+ let result = vanishedByAuthor.get(pubkey)
+ if (!result) {
+ result = options.userRepository.isVanished(pubkey)
+ vanishedByAuthor.set(pubkey, result)
+ }
+
+ return result
+ }
+
+ const flush = async () => {
+ inserted += await eventRepository.createMany(regularEvents.splice(0))
+ inserted += await eventRepository.upsertMany(replaceableEvents.splice(0))
+ }
for (const event of events) {
+ if (options.userRepository && isRequestToVanishEvent(event, options.relayUrl)) {
+ // Flush pending writes before deleting, so the vanish applies after
+ // everything accepted ahead of it in this batch.
+ await flush()
+ await eventRepository.deleteByPubkeyExceptKinds(event.pubkey, [EventKinds.REQUEST_TO_VANISH])
+ inserted += await eventRepository.create(enrichEventMetadata(event))
+ await options.userRepository.setVanished(event.pubkey, true)
+ vanishedByAuthor.set(event.pubkey, Promise.resolve(true))
+ continue
+ }
+
+ if (await isVanished(event.pubkey)) {
+ continue
+ }
+
if (isEphemeralEvent(event)) {
continue
}
if (isDeleteEvent(event)) {
// flush pending batches before applying deletes
- inserted += await eventRepository.createMany(regularEvents.splice(0))
- inserted += await eventRepository.upsertMany(replaceableEvents.splice(0))
+ await flush()
const eventIdsToDelete = event.tags.reduce(
(ids, tag) =>
@@ -135,16 +182,13 @@ export const createEventBatchPersister =
}
// flush remaining
- inserted += await eventRepository.createMany(regularEvents)
- inserted += await eventRepository.upsertMany(replaceableEvents)
+ await flush()
return inserted
}
export class EventImportService {
- public constructor(
- private readonly persistBatch: (events: Event[]) => Promise,
- ) {}
+ public constructor(private readonly persistBatch: (events: Event[]) => Promise) {}
public async importFromReadable(
input: NodeJS.ReadableStream,
diff --git a/src/utils/admin-diagnostics-export.ts b/src/utils/admin-diagnostics-export.ts
index 0511eb3a1..ade762e62 100644
--- a/src/utils/admin-diagnostics-export.ts
+++ b/src/utils/admin-diagnostics-export.ts
@@ -4,9 +4,10 @@ import { join } from 'path'
import { Settings } from '../@types/settings'
import { RedisAdapter } from '../adapters/redis-adapter'
import { getCacheClient } from '../cache/client'
-import { AdminOpsStore } from './admin-ops-store'
import { collectAdminHealthSnapshot } from './admin-health'
import { getRedactedAdminNotifications } from './admin-notifications-settings'
+import { AdminOpsStore } from './admin-ops-store'
+import { PeeringSyncStateStore } from './peering-sync-state'
import { RelayProbeSnapshotStore } from './relay-probe-snapshot'
import { loadMergedSettings } from './settings-config'
import { redactSettingsSecrets } from './settings-redaction'
@@ -22,6 +23,7 @@ export interface AdminDiagnosticsExport {
}
operations: {
workers: Awaited>
+ peering: Awaited>
}
}
@@ -41,11 +43,13 @@ export const buildAdminDiagnosticsExport = async (
const cache = new RedisAdapter(getCacheClient())
const opsStore = new AdminOpsStore(cache)
const snapshotStore = new RelayProbeSnapshotStore(cache)
+ const peeringStore = new PeeringSyncStateStore(cache)
- const [health, workers, latest] = await Promise.all([
+ const [health, workers, latest, peering] = await Promise.all([
collectAdminHealthSnapshot(),
opsStore.listWorkerHeartbeats(),
snapshotStore.getLatest(),
+ peeringStore.list(),
])
const settings = redactSettingsSecrets(getSettings()) as Settings
@@ -57,6 +61,6 @@ export const buildAdminDiagnosticsExport = async (
settings,
notifications: getRedactedAdminNotifications(),
networkHealth: { latest },
- operations: { workers },
+ operations: { workers, peering },
}
}
diff --git a/src/utils/admin-ops-config.ts b/src/utils/admin-ops-config.ts
index 6fdaee741..c3369a49f 100644
--- a/src/utils/admin-ops-config.ts
+++ b/src/utils/admin-ops-config.ts
@@ -30,6 +30,14 @@ export const listConfiguredWorkers = (settings: Settings): AdminOpsConfiguredWor
label: 'Maintenance',
})
+ if ((settings.peering?.peers?.length ?? 0) > 0) {
+ workers.push({
+ type: 'peering-sync',
+ enabled: true,
+ label: 'Peering sync',
+ })
+ }
+
if (settings.nip66?.enabled) {
workers.push({
type: 'relay-monitor',
diff --git a/src/utils/admin-peering.ts b/src/utils/admin-peering.ts
new file mode 100644
index 000000000..ca37f8abd
--- /dev/null
+++ b/src/utils/admin-peering.ts
@@ -0,0 +1,63 @@
+import { PeeringPeerSyncState } from '../@types/peering'
+import { Settings } from '../@types/settings'
+import { parsePeeringPeerPubkey } from './peering-auth'
+import { isPeeringSyncStateStale, PEERING_SYNC_STATE_STALE_AFTER_SECONDS } from './peering-sync-state'
+
+export interface AdminPeeringRow {
+ pubkey: string
+ relayUrl: string
+ configured: boolean
+ enabled: boolean
+ stale: boolean
+ state?: PeeringPeerSyncState
+}
+
+export const mergePeeringRows = (
+ settings: Settings,
+ states: PeeringPeerSyncState[],
+ staleAfterSeconds = PEERING_SYNC_STATE_STALE_AFTER_SECONDS,
+): AdminPeeringRow[] => {
+ const statesByPubkey = new Map(states.map((state) => [state.pubkey.toLowerCase(), state]))
+ const rows: AdminPeeringRow[] = []
+ const matched = new Set()
+
+ for (const peer of settings.peering?.peers ?? []) {
+ const pubkey = parsePeeringPeerPubkey(peer.pubkey) ?? peer.pubkey
+ const normalized = pubkey.toLowerCase()
+ const stored = statesByPubkey.get(normalized)
+ const state = stored?.relayUrl === peer.relayUrl ? stored : undefined
+ const enabled = peer.enabled !== false
+
+ matched.add(normalized)
+ rows.push({
+ pubkey,
+ relayUrl: peer.relayUrl,
+ configured: true,
+ enabled,
+ stale: enabled && (!state || isPeeringSyncStateStale(state.updatedAt, staleAfterSeconds)),
+ state,
+ })
+ }
+
+ // A peer removed from settings can still have a stored state row until its TTL
+ // expires. Showing it makes a stale relationship obvious instead of hiding it.
+ for (const state of states) {
+ if (matched.has(state.pubkey.toLowerCase())) {
+ continue
+ }
+
+ rows.push({
+ pubkey: state.pubkey,
+ relayUrl: state.relayUrl,
+ configured: false,
+ enabled: false,
+ stale: isPeeringSyncStateStale(state.updatedAt, staleAfterSeconds),
+ state,
+ })
+ }
+
+ return rows.sort((left, right) => {
+ const relayCompare = left.relayUrl.localeCompare(right.relayUrl)
+ return relayCompare !== 0 ? relayCompare : left.pubkey.localeCompare(right.pubkey)
+ })
+}
diff --git a/src/utils/event.ts b/src/utils/event.ts
index fe4916661..1e2f28810 100644
--- a/src/utils/event.ts
+++ b/src/utils/event.ts
@@ -1,16 +1,16 @@
import * as secp256k1 from '@noble/secp256k1'
-import { ALL_RELAYS, EventKinds, EventTags } from '../constants/base'
+import cluster from 'cluster'
import { applySpec, pipe, prop } from 'ramda'
-import { CanonicalEvent, DBEvent, Event, UnidentifiedEvent, UnsignedEvent } from '../@types/event'
import { EventId, Pubkey, Tag } from '../@types/base'
-import cluster from 'cluster'
-import { deriveFromSecret } from './secret'
+import { CanonicalEvent, DBEvent, Event, UnidentifiedEvent, UnsignedEvent } from '../@types/event'
import { EventKindsRange } from '../@types/settings'
-import { fromBuffer } from './transform'
-import { getLeadingZeroBits } from './proof-of-work'
-import { isGenericTagQuery, isGeohashPrefixCriterion, stripGeohashPrefixWildcard } from './filter'
import { SubscriptionFilter } from '../@types/subscription'
import { WebSocketServerAdapterEvent } from '../constants/adapter'
+import { ALL_RELAYS, EventKinds, EventTags } from '../constants/base'
+import { isGenericTagQuery, isGeohashPrefixCriterion, stripGeohashPrefixWildcard } from './filter'
+import { getLeadingZeroBits } from './proof-of-work'
+import { deriveFromSecret } from './secret'
+import { fromBuffer } from './transform'
export const serializeEvent = (event: UnidentifiedEvent): CanonicalEvent => [
0,
@@ -238,7 +238,9 @@ export const isRequestToVanishEvent = (event: Event, relayUrl?: string): boolean
const relayTags = event.tags.filter((tag) => tag.length >= 2 && tag[0] === EventTags.Relay).map((tag) => tag[1])
- return relayTags.length > 0 && relayTags.every((relay) => relay === relayUrl || relay === ALL_RELAYS)
+ // NIP-62: a request may name several relays. It applies here when any target
+ // is this relay or ALL_RELAYS; it does not need every target to match.
+ return relayTags.some((relay) => relay === relayUrl || relay === ALL_RELAYS)
}
export const isExpiredEvent = (event: Event): boolean => {
diff --git a/src/utils/peering-advertisement.ts b/src/utils/peering-advertisement.ts
new file mode 100644
index 000000000..36ce2f706
--- /dev/null
+++ b/src/utils/peering-advertisement.ts
@@ -0,0 +1,178 @@
+import axios from 'axios'
+import { z } from 'zod'
+import { AdvertisedPeeringScope, PeeringAdvertisement } from '../@types/peering'
+import { EventKindsRange, PeeringTerm } from '../@types/settings'
+import { MAX_PEERING_KIND } from './peering-scope'
+import { isNip11FetchTargetSafe, safeNip11Lookup } from './relay-probe/nip11-probe'
+import { parseProbeTarget } from './relay-probe/target'
+
+const MAX_NIP11_RESPONSE_BYTES = 256 * 1024
+const DEFAULT_NIP11_TIMEOUT_MS = 10_000
+
+const peeringKindSchema = z.union([
+ z.number().int().min(0).max(MAX_PEERING_KIND),
+ z.tuple([z.number().int().min(0).max(MAX_PEERING_KIND), z.number().int().min(0).max(MAX_PEERING_KIND)]),
+])
+
+const advertisedScopeSchema = z
+ .object({
+ kinds: z.array(peeringKindSchema).min(1),
+ since: z.number().int().min(0).optional(),
+ until: z.number().int().min(0).optional(),
+ window_seconds: z.number().int().positive().optional(),
+ // Older or hand-written documents sometimes use camelCase; accept both.
+ windowSeconds: z.number().int().positive().optional(),
+ })
+ .passthrough()
+ .superRefine((scope, ctx) => {
+ const windowSeconds = scope.window_seconds ?? scope.windowSeconds
+ if (windowSeconds !== undefined && (scope.since !== undefined || scope.until !== undefined)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'window_seconds cannot be combined with since or until',
+ })
+ }
+
+ if (scope.since !== undefined && scope.until !== undefined && scope.since > scope.until) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'since must be less than or equal to until',
+ })
+ }
+
+ for (const kind of scope.kinds) {
+ if (Array.isArray(kind) && kind[0] > kind[1]) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'kind range start must be less than or equal to its end',
+ })
+ }
+ }
+ })
+
+const peeringTermSchema = z.object({
+ amount: z.number().finite().nonnegative(),
+ unit: z.string().min(1),
+ period: z.number().int().positive(),
+})
+
+const peeringDocumentSchema = z
+ .object({
+ enabled: z.boolean().optional(),
+ scopes: z.array(advertisedScopeSchema).optional(),
+ terms: z.array(peeringTermSchema).optional(),
+ })
+ .passthrough()
+
+const nip11Schema = z
+ .object({
+ self: z
+ .string()
+ .regex(/^[0-9a-fA-F]{64}$/)
+ .optional(),
+ supported_nips: z.array(z.number().int().positive()).optional(),
+ peering: peeringDocumentSchema.optional(),
+ })
+ .passthrough()
+
+export type Nip11PeeringDocument = z.infer
+
+export const parsePeeringAdvertisement = (document: unknown): PeeringAdvertisement => {
+ const parsed = nip11Schema.safeParse(document)
+ if (!parsed.success) {
+ const reason = parsed.error.issues.map((issue) => issue.message).join('; ')
+
+ throw new Error(`invalid NIP-11 document: ${reason}`)
+ }
+
+ const peering = parsed.data.peering
+ const enabled = peering?.enabled === true
+
+ if (!enabled) {
+ return {
+ enabled: false,
+ scopes: [],
+ terms: [],
+ self: parsed.data.self?.toLowerCase(),
+ supportedNips: parsed.data.supported_nips ?? [],
+ }
+ }
+
+ const scopes = peering?.scopes ?? []
+ if (scopes.length === 0) {
+ throw new Error('peer advertises peering but offers no scopes')
+ }
+
+ return {
+ enabled,
+ scopes: scopes.map(
+ (scope): AdvertisedPeeringScope => ({
+ kinds: scope.kinds as (number | EventKindsRange)[],
+ ...(scope.since !== undefined ? { since: scope.since } : {}),
+ ...(scope.until !== undefined ? { until: scope.until } : {}),
+ ...(scope.window_seconds !== undefined || scope.windowSeconds !== undefined
+ ? { windowSeconds: scope.window_seconds ?? scope.windowSeconds }
+ : {}),
+ }),
+ ),
+ terms: (peering?.terms ?? []).map(
+ (term): PeeringTerm => ({
+ amount: term.amount,
+ unit: term.unit,
+ period: term.period,
+ }),
+ ),
+ self: parsed.data.self?.toLowerCase(),
+ supportedNips: parsed.data.supported_nips ?? [],
+ }
+}
+
+export const nip11UrlForRelay = (relayUrl: string): string => parseProbeTarget(relayUrl).nip11Url
+
+export interface FetchPeeringAdvertisementOptions {
+ timeoutMs?: number
+}
+
+/**
+ * Reads a peer's NIP-11 document and returns the peering terms it advertises.
+ * Phases that act on those terms (sync here, settlement later) share this one
+ * reader instead of each re-implementing discovery.
+ */
+export const fetchPeeringAdvertisement = async (
+ relayUrl: string,
+ options: FetchPeeringAdvertisementOptions = {},
+): Promise => {
+ const timeoutMs = options.timeoutMs ?? DEFAULT_NIP11_TIMEOUT_MS
+ const url = nip11UrlForRelay(relayUrl)
+
+ if (!isNip11FetchTargetSafe(url)) {
+ throw new Error(`unable to read peering advertisement from ${relayUrl}: refused unsafe NIP-11 fetch target`)
+ }
+
+ try {
+ const response = await axios.get(url, {
+ timeout: timeoutMs,
+ headers: { Accept: 'application/nostr+json' },
+ responseType: 'json',
+ validateStatus: (status) => status === 200,
+ maxRedirects: 1,
+ maxContentLength: MAX_NIP11_RESPONSE_BYTES,
+ maxBodyLength: MAX_NIP11_RESPONSE_BYTES,
+ // Direct egress so safeNip11Lookup actually sees the peer host. A proxy
+ // would resolve the destination itself and bypass the private-address check.
+ proxy: false,
+ lookup: safeNip11Lookup,
+ beforeRedirect: (redirect: { href?: string; protocol?: string; hostname?: string }) => {
+ const href = redirect.href ?? `${redirect.protocol ?? ''}//${redirect.hostname ?? ''}`
+ if (!isNip11FetchTargetSafe(href)) {
+ throw new Error(`refused redirect to unsafe NIP-11 target: ${href}`)
+ }
+ },
+ })
+
+ return parsePeeringAdvertisement(response.data)
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ throw new Error(`unable to read peering advertisement from ${relayUrl}: ${message}`)
+ }
+}
diff --git a/src/utils/peering-auth.ts b/src/utils/peering-auth.ts
index 623eb437a..d076eda4c 100644
--- a/src/utils/peering-auth.ts
+++ b/src/utils/peering-auth.ts
@@ -8,6 +8,8 @@ import { getPublicKey, identifyEvent, signEvent } from './event'
import { parseRelayPubkey } from './nip43-invites'
export const PEERING_AUTH_TIMEOUT_MS = 10_000
+/** One inbound frame from a peer relay. Matches the relay's default max payload with headroom. */
+export const PEERING_MAX_PAYLOAD_BYTES = 2 * 1024 * 1024
export interface PeeringAuthEventOptions {
relayUrl: string
@@ -21,6 +23,7 @@ export interface AuthenticatePeerOptions {
privateKey: string
timeoutMs?: number
createdAt?: number
+ maxPayloadBytes?: number
}
export interface AuthenticatedPeerConnection {
@@ -65,6 +68,16 @@ export const findConfiguredPeeringPeer = (
export const isConfiguredPeeringPeer = (settings: Pick | undefined, pubkey: string): boolean =>
typeof findConfiguredPeeringPeer(settings, pubkey) !== 'undefined'
+/** Enabled, minimally well-formed peers to sync from. Settings validation rejects the rest. */
+export const listEnabledPeeringPeers = (settings: Pick | undefined): PeeringPeer[] =>
+ (settings?.peering?.peers ?? []).filter(
+ (peer) =>
+ peer.enabled !== false &&
+ typeof peer.relayUrl === 'string' &&
+ peer.relayUrl.trim().length > 0 &&
+ parsePeeringPeerPubkey(peer.pubkey) !== undefined,
+ )
+
/**
* Builds the NIP-42 AUTH event that proves this relay's own identity to a
* source relay. Signed with the relay keypair, so the event's pubkey is the
@@ -112,12 +125,18 @@ const closeQuietly = (socket: WebSocket): void => {
* leaving the socket open for the caller (a live feed, negentropy session, ...).
*/
export const authenticateToPeeringRelay = (options: AuthenticatePeerOptions): Promise => {
- const { relayUrl, privateKey, timeoutMs = PEERING_AUTH_TIMEOUT_MS, createdAt } = options
+ const {
+ relayUrl,
+ privateKey,
+ timeoutMs = PEERING_AUTH_TIMEOUT_MS,
+ createdAt,
+ maxPayloadBytes = PEERING_MAX_PAYLOAD_BYTES,
+ } = options
return new Promise((resolve, reject) => {
let socket: WebSocket
try {
- socket = new WebSocket(relayUrl)
+ socket = new WebSocket(relayUrl, { maxPayload: maxPayloadBytes })
} catch (error) {
reject(error instanceof Error ? error : new Error('Unable to open the peering socket'))
return
diff --git a/src/utils/peering-connection.ts b/src/utils/peering-connection.ts
new file mode 100644
index 000000000..aae94873d
--- /dev/null
+++ b/src/utils/peering-connection.ts
@@ -0,0 +1,399 @@
+import WebSocket, { RawData } from 'ws'
+
+import { Event } from '../@types/event'
+import { ReconciliationItem } from '../@types/repositories'
+import { SubscriptionFilter } from '../@types/subscription'
+import { createLogger } from '../factories/logger-factory'
+import { Negentropy, NegentropyStorageVector, ReconcileResult } from './negentropy'
+import { NEGENTROPY_FRAME_SIZE_LIMIT } from './nip77'
+
+const logger = createLogger('peering-connection')
+
+const DEFAULT_REQUEST_TIMEOUT_MS = 30_000
+/** Upper bound for one fetched id batch's EVENT payloads, independent of count. */
+export const PEERING_FETCH_MAX_BYTES = 8 * 1024 * 1024
+
+/** A fetch reply exceeded its byte budget. The worker retries with smaller batches. */
+export class PeeringFetchOverflowError extends Error {
+ public constructor() {
+ super('peer sent more event data than this request allows')
+ this.name = 'PeeringFetchOverflowError'
+ }
+}
+
+export interface PeeringSyncConnection {
+ /** Reconciles one filter against the peer, returning the ids each side lacks. */
+ reconcile(filter: SubscriptionFilter, items: ReconciliationItem[], timeoutMs?: number): Promise
+ /** Fetches specific event ids over an ordinary REQ; resolves after EOSE. */
+ fetchEvents(ids: string[], timeoutMs?: number, maxBytes?: number): Promise
+ /** Opens a long-lived REQ and resolves after its EOSE. Events keep arriving afterwards. */
+ subscribe(filters: SubscriptionFilter[], onEvent: (event: Event) => void, timeoutMs?: number): Promise
+ /** Resolves when the socket closes, cleanly or not. */
+ waitForClose(): Promise
+ close(): void
+}
+
+interface NegWaiter {
+ resolve: (payload: string) => void
+ reject: (error: Error) => void
+ timer: NodeJS.Timeout
+}
+
+interface RequestState {
+ onEvent: (event: Event) => void
+ resolve?: () => void
+ reject?: (error: Error) => void
+ timer?: NodeJS.Timeout
+ closed: boolean
+ /** A live feed must end the connection on CLOSED so the worker reconnects. */
+ persistent?: boolean
+ /** Only ids still in this set are accepted; deletion makes fetches duplicate-safe. */
+ expectedIds?: Set
+ receivedBytes?: number
+ maxBytes?: number
+}
+
+const toHex = (bytes: Uint8Array): string => Buffer.from(bytes).toString('hex')
+
+const messageType = (message: unknown[]): string => (typeof message[0] === 'string' ? message[0] : '')
+
+const requestId = (message: unknown[]): string | undefined => (typeof message[1] === 'string' ? message[1] : undefined)
+
+const parseMessage = (raw: RawData): unknown[] | undefined => {
+ try {
+ const parsed = JSON.parse(raw.toString())
+
+ return Array.isArray(parsed) ? parsed : undefined
+ } catch {
+ return undefined
+ }
+}
+
+/**
+ * One authenticated outbound peering socket, speaking only the wire primitives
+ * the relay already implements for clients: AUTH (done before construction),
+ * NEG-OPEN/NEG-MSG/NEG-CLOSE for history and REQ/EVENT/EOSE for the live feed.
+ */
+export class PeeringConnection implements PeeringSyncConnection {
+ private readonly negWaiters = new Map()
+ private readonly requests = new Map()
+ private readonly closePromise: Promise
+ private closed = false
+ private requestCounter = 0
+
+ public constructor(private readonly socket: WebSocket) {
+ this.closePromise = new Promise((resolve) => {
+ socket.once('close', () => {
+ this.closed = true
+ this.failAll(new Error('peering connection closed'))
+ resolve()
+ })
+ })
+
+ socket.on('message', this.onMessage.bind(this))
+ socket.on('error', (error) => {
+ logger('peer socket error: %o', error)
+ })
+ }
+
+ public async reconcile(
+ filter: SubscriptionFilter,
+ items: ReconciliationItem[],
+ timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
+ ): Promise {
+ const subscriptionId = this.nextRequestId('neg')
+ const storage = new NegentropyStorageVector()
+
+ for (const item of items) {
+ storage.insert(item.event_created_at, item.event_id)
+ }
+ storage.seal()
+
+ const negentropy = new Negentropy(storage, NEGENTROPY_FRAME_SIZE_LIMIT)
+ const haveIds: string[] = []
+ const needIds: string[] = []
+
+ let output: Uint8Array | null = negentropy.initiate()
+ let waiting = this.waitForNegMsg(subscriptionId, timeoutMs)
+
+ try {
+ this.send(['NEG-OPEN', subscriptionId, filter, toHex(output)])
+
+ while (output !== null) {
+ const payload = await waiting
+
+ const result = negentropy.reconcile(Buffer.from(payload, 'hex'))
+ haveIds.push(...result.haveIds)
+ needIds.push(...result.needIds)
+ output = result.output
+
+ if (output !== null) {
+ waiting = this.waitForNegMsg(subscriptionId, timeoutMs)
+ this.send(['NEG-MSG', subscriptionId, toHex(output)])
+ }
+ }
+ } finally {
+ this.clearNegWaiter(subscriptionId)
+ if (!this.closed) {
+ try {
+ this.send(['NEG-CLOSE', subscriptionId])
+ } catch {
+ // The socket may have closed while the final message was in flight.
+ }
+ }
+ }
+
+ return { output: null, haveIds, needIds }
+ }
+
+ public async fetchEvents(
+ ids: string[],
+ timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
+ maxBytes = PEERING_FETCH_MAX_BYTES,
+ ): Promise {
+ if (ids.length === 0) {
+ return []
+ }
+
+ const subscriptionId = this.nextRequestId('fetch')
+ const events: Event[] = []
+
+ try {
+ await this.openSubscription(subscriptionId, [{ ids }], (event) => events.push(event), timeoutMs, false, {
+ expectedIds: new Set(ids),
+ maxBytes,
+ })
+
+ return events
+ } finally {
+ this.closeSubscription(subscriptionId)
+ }
+ }
+
+ public subscribe(
+ filters: SubscriptionFilter[],
+ onEvent: (event: Event) => void,
+ timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
+ ): Promise {
+ const subscriptionId = this.nextRequestId('live')
+
+ return this.openSubscription(subscriptionId, filters, onEvent, timeoutMs, true)
+ }
+ public waitForClose(): Promise {
+ return this.closePromise
+ }
+
+ public close(): void {
+ if (this.closed) {
+ return
+ }
+
+ this.closed = true
+ this.failAll(new Error('peering connection closed'))
+ try {
+ this.socket.close()
+ } catch {
+ // Already closing; the close event will settle the promise.
+ }
+ }
+
+ private openSubscription(
+ subscriptionId: string,
+ filters: SubscriptionFilter[],
+ onEvent: (event: Event) => void,
+ timeoutMs: number,
+ persistent = false,
+ bounds: Pick = {},
+ ): Promise {
+ const state: RequestState = {
+ onEvent,
+ closed: false,
+ persistent,
+ ...bounds,
+ }
+
+ this.requests.set(subscriptionId, state)
+
+ return new Promise((resolve, reject) => {
+ state.resolve = resolve
+ state.reject = reject
+ state.timer = setTimeout(() => {
+ if (state.closed) {
+ return
+ }
+ state.closed = true
+ this.requests.delete(subscriptionId)
+ reject(new Error('timed out waiting for the peer to finish the stored query'))
+ }, timeoutMs)
+ state.timer.unref()
+
+ try {
+ this.send(['REQ', subscriptionId, ...filters])
+ } catch (error) {
+ this.clearRequest(state)
+ this.requests.delete(subscriptionId)
+ reject(error instanceof Error ? error : new Error(String(error)))
+ }
+ })
+ }
+
+ private closeSubscription(subscriptionId: string): void {
+ const state = this.requests.get(subscriptionId)
+ if (state) {
+ this.clearRequest(state)
+ this.requests.delete(subscriptionId)
+ }
+
+ if (!this.closed) {
+ try {
+ this.send(['CLOSE', subscriptionId])
+ } catch {
+ // The socket may already be closing.
+ }
+ }
+ }
+
+ private waitForNegMsg(subscriptionId: string, timeoutMs: number): Promise {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ this.negWaiters.delete(subscriptionId)
+ reject(new Error('timed out waiting for a negentropy reply'))
+ }, timeoutMs)
+ timer.unref()
+
+ this.negWaiters.set(subscriptionId, { resolve, reject, timer })
+ })
+ }
+
+ private clearNegWaiter(subscriptionId: string): void {
+ const waiter = this.negWaiters.get(subscriptionId)
+ if (!waiter) {
+ return
+ }
+
+ clearTimeout(waiter.timer)
+ this.negWaiters.delete(subscriptionId)
+ }
+
+ private onMessage(raw: RawData): void {
+ const message = parseMessage(raw)
+ if (!message) {
+ return
+ }
+
+ const subscriptionId = requestId(message)
+ if (!subscriptionId) {
+ return
+ }
+
+ switch (messageType(message)) {
+ case 'NEG-MSG': {
+ const waiter = this.negWaiters.get(subscriptionId)
+ if (!waiter || typeof message[2] !== 'string') {
+ return
+ }
+ clearTimeout(waiter.timer)
+ this.negWaiters.delete(subscriptionId)
+ waiter.resolve(message[2])
+ return
+ }
+ case 'NEG-ERR': {
+ const waiter = this.negWaiters.get(subscriptionId)
+ if (!waiter) {
+ return
+ }
+ clearTimeout(waiter.timer)
+ this.negWaiters.delete(subscriptionId)
+ waiter.reject(new Error(typeof message[2] === 'string' ? message[2] : 'negentropy request rejected'))
+ return
+ }
+ case 'EVENT': {
+ const state = this.requests.get(subscriptionId)
+ const event = message[2]
+ if (!state || typeof event !== 'object' || event === null) {
+ return
+ }
+
+ const parsedEvent = event as Event
+ if (state.expectedIds && !state.expectedIds.delete(parsedEvent.id)) {
+ return
+ }
+
+ const size = Buffer.byteLength(JSON.stringify(parsedEvent))
+ state.receivedBytes = (state.receivedBytes ?? 0) + size
+ if (state.maxBytes !== undefined && state.receivedBytes > state.maxBytes) {
+ this.clearRequest(state)
+ this.requests.delete(subscriptionId)
+ state.reject?.(new PeeringFetchOverflowError())
+ return
+ }
+
+ state.onEvent(parsedEvent)
+ return
+ }
+ case 'EOSE': {
+ const state = this.requests.get(subscriptionId)
+ if (!state) {
+ return
+ }
+ if (state.timer) {
+ clearTimeout(state.timer)
+ }
+ state.resolve?.()
+ return
+ }
+ case 'CLOSED': {
+ const state = this.requests.get(subscriptionId)
+ if (!state) {
+ return
+ }
+ const reason = typeof message[2] === 'string' ? message[2] : 'peer closed the subscription'
+ this.clearRequest(state)
+ this.requests.delete(subscriptionId)
+ state.reject?.(new Error(reason))
+ if (state.persistent) {
+ logger.warn('peer closed the live subscription: %s', reason)
+ this.close()
+ }
+ return
+ }
+ default:
+ return
+ }
+ }
+
+ private clearRequest(state: RequestState): void {
+ state.closed = true
+ if (state.timer) {
+ clearTimeout(state.timer)
+ }
+ }
+
+ private failAll(error: Error): void {
+ for (const waiter of this.negWaiters.values()) {
+ clearTimeout(waiter.timer)
+ waiter.reject(error)
+ }
+ this.negWaiters.clear()
+
+ for (const state of this.requests.values()) {
+ this.clearRequest(state)
+ state.reject?.(error)
+ }
+ this.requests.clear()
+ }
+
+ private send(message: unknown[]): void {
+ if (this.closed) {
+ throw new Error('peering connection is closed')
+ }
+
+ this.socket.send(JSON.stringify(message))
+ }
+
+ private nextRequestId(prefix: string): string {
+ this.requestCounter += 1
+
+ return `peering-${prefix}-${this.requestCounter}`
+ }
+}
diff --git a/src/utils/peering-scope.ts b/src/utils/peering-scope.ts
new file mode 100644
index 000000000..ba1639dd6
--- /dev/null
+++ b/src/utils/peering-scope.ts
@@ -0,0 +1,145 @@
+import { EventKindsRange, PeeringScope } from '../@types/settings'
+import { SubscriptionFilter } from '../@types/subscription'
+import { EventKinds } from '../constants/base'
+
+export const MAX_PEERING_KIND = 65535
+
+/**
+ * Overlap used when the live subscription starts. NIP-01 REQ has no "future
+ * only" mode, so ask for a short tail as well: an event published while
+ * negentropy runs is delivered either by reconciliation or by the live
+ * subscription, and duplicate inserts are harmless.
+ */
+export const PEERING_LIVE_OVERLAP_SECONDS = 60
+
+/**
+ * Keep each filter under the default peer maxFilterValues (10,000) with
+ * headroom. A broad scope such as `[[0, 65535]]` must become several filters
+ * or the source rejects the whole request before any history is synced.
+ */
+export const PEERING_MAX_FILTER_KINDS = 5000
+
+/** Expand `[min, max]` scopes into the explicit kind list NIP-01 filters require. */
+export const expandPeeringKinds = (kinds: (EventKinds | EventKindsRange)[]): number[] => {
+ const expanded = new Set()
+
+ for (const kind of kinds) {
+ if (typeof kind === 'number') {
+ expanded.add(kind)
+ continue
+ }
+
+ for (let value = kind[0]; value <= kind[1]; value++) {
+ expanded.add(value)
+ }
+ }
+
+ return [...expanded].sort((left, right) => left - right)
+}
+
+const scopeKinds = (scope: PeeringScope): number[] => expandPeeringKinds(scope.kinds)
+
+const chunk = (values: T[], size: number): T[][] => {
+ const chunks: T[][] = []
+ for (let offset = 0; offset < values.length; offset += size) {
+ chunks.push(values.slice(offset, offset + size))
+ }
+
+ return chunks
+}
+
+const applyScopeTime = (filter: SubscriptionFilter, scope: PeeringScope, nowSeconds: number): SubscriptionFilter => {
+ if (typeof scope.windowSeconds === 'number') {
+ filter.since = Math.max(0, nowSeconds - scope.windowSeconds)
+ } else {
+ if (typeof scope.since === 'number') {
+ filter.since = scope.since
+ }
+
+ if (typeof scope.until === 'number') {
+ filter.until = scope.until
+ }
+ }
+
+ return filter
+}
+
+/**
+ * The filter used to reconcile one advertised scope when it fits in one
+ * request. A rolling window becomes an absolute `since` at the moment of the
+ * reconciliation; `limit` is never set because negentropy compares whole sets
+ * rather than pages.
+ */
+export const peeringScopeToFilter = (scope: PeeringScope, nowSeconds: number): SubscriptionFilter =>
+ applyScopeTime({ kinds: scopeKinds(scope) }, scope, nowSeconds)
+
+/**
+ * Reconcile filters for one scope. Wide kind ranges are split into chunks so
+ * each request stays within a peer's `maxFilterValues`.
+ */
+export const peeringScopeToFilters = (
+ scope: PeeringScope,
+ nowSeconds: number,
+ maxKinds = PEERING_MAX_FILTER_KINDS,
+): SubscriptionFilter[] =>
+ chunk(scopeKinds(scope), Math.max(1, maxKinds)).map((kinds) => applyScopeTime({ kinds }, scope, nowSeconds))
+
+const isCurrentScope = (scope: PeeringScope, nowSeconds: number): boolean =>
+ typeof scope.until !== 'number' || scope.until >= nowSeconds
+
+/**
+ * Filters for the long-lived subscription. A scope whose `until` already passed
+ * is historical only and gets no live subscription. The subscription starts at
+ * the recent overlap, but never earlier than the scope itself: a 5-second
+ * rolling window must not import the last 60 seconds by accident.
+ */
+export const peeringScopesToLiveFilters = (
+ scopes: PeeringScope[],
+ nowSeconds: number,
+ overlapSeconds = PEERING_LIVE_OVERLAP_SECONDS,
+): SubscriptionFilter[] =>
+ scopes
+ .filter((scope) => isCurrentScope(scope, nowSeconds))
+ .flatMap((scope) =>
+ peeringScopeToFilters(scope, nowSeconds).map((filter) => {
+ const overlapSince = Math.max(0, nowSeconds - overlapSeconds)
+
+ if (typeof scope.windowSeconds === 'number') {
+ // The scope's own cutoff is already the newer of the two for short
+ // windows; for long windows the overlap is the newer one.
+ filter.since = Math.max(filter.since ?? 0, overlapSince)
+ delete filter.until
+ } else {
+ filter.since = Math.max(filter.since ?? 0, overlapSince)
+ }
+
+ return filter
+ }),
+ )
+
+export const isCurrentPeeringScope = (scope: PeeringScope, nowSeconds: number): boolean =>
+ isCurrentScope(scope, nowSeconds)
+
+/**
+ * Splits one reconciliation filter at its midpoint in time when a peer rejects
+ * the set as too large. Returns undefined when the range cannot be split any
+ * further, so the caller reports the original rejection instead of looping
+ * forever.
+ */
+export const splitPeeringFilter = (
+ filter: SubscriptionFilter,
+ nowSeconds: number,
+): [SubscriptionFilter, SubscriptionFilter] | undefined => {
+ const since = filter.since ?? 0
+ const until = filter.until ?? nowSeconds
+ if (until <= since) {
+ return undefined
+ }
+
+ const midpoint = Math.floor((since + until) / 2)
+
+ return [
+ { ...filter, since, until: midpoint },
+ { ...filter, since: midpoint + 1, until },
+ ]
+}
diff --git a/src/utils/peering-sync-state.ts b/src/utils/peering-sync-state.ts
new file mode 100644
index 000000000..c22e6ff29
--- /dev/null
+++ b/src/utils/peering-sync-state.ts
@@ -0,0 +1,62 @@
+import { ICacheAdapter } from '../@types/adapters'
+import { PeeringPeerSyncState } from '../@types/peering'
+import { createLogger } from '../factories/logger-factory'
+import { sanitizeAdminOpsError } from './admin-ops-sanitize'
+
+const logger = createLogger('peering-sync-state')
+
+export const PEERING_SYNC_STATE_INDEX_KEY = 'admin:peering:sync:index'
+export const PEERING_SYNC_STATE_TTL_SECONDS = 7 * 24 * 60 * 60
+/** A peer whose state has not moved for two reconciliation intervals is stale. */
+export const PEERING_SYNC_STATE_STALE_AFTER_SECONDS = 30 * 60
+
+const stateKey = (pubkey: string): string => `admin:peering:sync:${pubkey.toLowerCase()}`
+
+export class PeeringSyncStateStore {
+ public constructor(private readonly cache: ICacheAdapter) {}
+
+ public async publish(state: PeeringPeerSyncState): Promise {
+ const sanitized: PeeringPeerSyncState = {
+ ...state,
+ pubkey: state.pubkey.toLowerCase(),
+ lastError: state.lastError !== undefined ? sanitizeAdminOpsError(state.lastError) : undefined,
+ }
+
+ await this.cache.setKey(stateKey(sanitized.pubkey), JSON.stringify(sanitized), PEERING_SYNC_STATE_TTL_SECONDS)
+ await this.cache.addToSet(PEERING_SYNC_STATE_INDEX_KEY, [sanitized.pubkey])
+ await this.cache.setKeyExpiry(PEERING_SYNC_STATE_INDEX_KEY, PEERING_SYNC_STATE_TTL_SECONDS)
+ }
+
+ public async list(): Promise {
+ const pubkeys = await this.cache.getSetMembers(PEERING_SYNC_STATE_INDEX_KEY)
+ const states: PeeringPeerSyncState[] = []
+
+ for (const pubkey of pubkeys) {
+ const raw = await this.cache.getKey(stateKey(pubkey))
+ if (!raw) {
+ continue
+ }
+
+ try {
+ states.push(JSON.parse(raw) as PeeringPeerSyncState)
+ } catch {
+ logger.warn('ignoring corrupt peering sync state for %s', pubkey)
+ }
+ }
+
+ return states.sort((left, right) => left.pubkey.localeCompare(right.pubkey))
+ }
+}
+
+export const isPeeringSyncStateStale = (updatedAtIso: string | undefined, staleAfterSeconds: number): boolean => {
+ if (!updatedAtIso) {
+ return true
+ }
+
+ const updatedAt = Date.parse(updatedAtIso)
+ if (!Number.isFinite(updatedAt)) {
+ return true
+ }
+
+ return Date.now() - updatedAt > staleAfterSeconds * 1000
+}
diff --git a/src/utils/relay-probe/nip11-probe.ts b/src/utils/relay-probe/nip11-probe.ts
index 306fa4661..36bfa7a13 100644
--- a/src/utils/relay-probe/nip11-probe.ts
+++ b/src/utils/relay-probe/nip11-probe.ts
@@ -1,3 +1,5 @@
+import { lookup as dnsLookup } from 'node:dns/promises'
+import { BlockList, isIP } from 'node:net'
import axios, { AxiosError } from 'axios'
import { z } from 'zod'
@@ -41,6 +43,69 @@ export interface Nip11Fetcher {
fetch(url: string, timeoutMs: number): Promise
}
+const blockedNip11Addresses = new BlockList()
+blockedNip11Addresses.addSubnet('0.0.0.0', 8, 'ipv4')
+blockedNip11Addresses.addSubnet('10.0.0.0', 8, 'ipv4')
+blockedNip11Addresses.addSubnet('100.64.0.0', 10, 'ipv4')
+blockedNip11Addresses.addSubnet('127.0.0.0', 8, 'ipv4')
+blockedNip11Addresses.addSubnet('169.254.0.0', 16, 'ipv4')
+blockedNip11Addresses.addSubnet('172.16.0.0', 12, 'ipv4')
+blockedNip11Addresses.addSubnet('192.0.0.0', 24, 'ipv4')
+blockedNip11Addresses.addSubnet('192.0.2.0', 24, 'ipv4')
+blockedNip11Addresses.addSubnet('192.168.0.0', 16, 'ipv4')
+blockedNip11Addresses.addSubnet('198.18.0.0', 15, 'ipv4')
+blockedNip11Addresses.addSubnet('198.51.100.0', 24, 'ipv4')
+blockedNip11Addresses.addSubnet('203.0.113.0', 24, 'ipv4')
+blockedNip11Addresses.addSubnet('224.0.0.0', 4, 'ipv4')
+blockedNip11Addresses.addSubnet('240.0.0.0', 4, 'ipv4')
+blockedNip11Addresses.addSubnet('::', 128, 'ipv6')
+blockedNip11Addresses.addSubnet('::1', 128, 'ipv6')
+blockedNip11Addresses.addSubnet('2001:db8::', 32, 'ipv6')
+blockedNip11Addresses.addSubnet('fc00::', 7, 'ipv6')
+blockedNip11Addresses.addSubnet('fe80::', 10, 'ipv6')
+blockedNip11Addresses.addSubnet('ff00::', 8, 'ipv6')
+
+const normalizeResolvedAddress = (address: string): string => {
+ const lower = address.toLowerCase()
+ const mapped = lower.startsWith('::ffff:') ? lower.slice('::ffff:'.length) : lower
+
+ return isIP(mapped) === 4 ? mapped : lower
+}
+
+export const isBlockedNip11Address = (address: string): boolean => {
+ const normalized = normalizeResolvedAddress(address)
+ const family = isIP(normalized)
+
+ if (family === 4) {
+ return blockedNip11Addresses.check(normalized, 'ipv4')
+ }
+
+ if (family === 6) {
+ return blockedNip11Addresses.check(normalized, 'ipv6')
+ }
+
+ return true
+}
+
+/**
+ * Resolve a NIP-11 host and refuse private/loopback/link-local results before
+ * the socket connects. String-only URL checks miss hostnames whose A/AAAA
+ * records point inward, including DNS rebinding after the literal check.
+ */
+export const safeNip11Lookup = async (
+ hostname: string,
+ options: object,
+): Promise<{ address: string; family: number }[]> => {
+ const result = await dnsLookup(hostname, { ...(options as any), all: true })
+ const addresses = Array.isArray(result) ? result : [result]
+
+ if (addresses.some((entry) => isBlockedNip11Address(entry.address))) {
+ throw new Error(`refused NIP-11 target ${hostname} resolving to a private address`)
+ }
+
+ return addresses
+}
+
/**
* Reject redirect targets that would turn relay probing into an SSRF primitive.
* Mirrors the NIP-05 verification guard in src/utils/nip05.ts.
@@ -101,6 +166,10 @@ export const createNodeNip11Fetcher = (): Nip11Fetcher => ({
maxRedirects: MAX_REDIRECTS,
maxContentLength: MAX_RESPONSE_BYTES,
maxBodyLength: MAX_RESPONSE_BYTES,
+ // Direct egress: a proxy would resolve/connect on our behalf and
+ // bypass safeNip11Lookup's private-address rejection.
+ proxy: false,
+ lookup: safeNip11Lookup,
beforeRedirect: (options: { href?: string; protocol?: string; hostname?: string }) => {
const href = options.href ?? `${options.protocol ?? ''}//${options.hostname ?? ''}`
if (!isNip11FetchTargetSafe(href)) {
diff --git a/test/integration/features/peering-sync/peering-sync.feature b/test/integration/features/peering-sync/peering-sync.feature
new file mode 100644
index 000000000..6849ffec4
--- /dev/null
+++ b/test/integration/features/peering-sync/peering-sync.feature
@@ -0,0 +1,15 @@
+Feature: Peering sync planning
+ Scenario: Wide peering scopes are chunked for a peer
+ When a peering scope covers kinds 0 through 7
+ Then the scope produces filters of at most 4 kinds
+
+ Scenario: Short rolling windows keep their own cutoff
+ When a peering scope uses a 5 second rolling window at 1000
+ Then its live filter starts at 995
+
+ Scenario: Oversized filter is split in time
+ When an unbounded peering filter is split at 1000
+ Then the split filters cover 0 to 500 and 501 to 1000
+
+ Scenario: NIP-11 private addresses are rejected
+ Then the NIP-11 address "127.0.0.1" is blocked
diff --git a/test/integration/features/peering-sync/peering-sync.feature.ts b/test/integration/features/peering-sync/peering-sync.feature.ts
new file mode 100644
index 000000000..6d1e27541
--- /dev/null
+++ b/test/integration/features/peering-sync/peering-sync.feature.ts
@@ -0,0 +1,59 @@
+import { Then, When, World } from '@cucumber/cucumber'
+import chai from 'chai'
+import { SubscriptionFilter } from '../../../../src/@types/subscription'
+import {
+ peeringScopesToLiveFilters,
+ peeringScopeToFilters,
+ splitPeeringFilter,
+} from '../../../../src/utils/peering-scope'
+import { isBlockedNip11Address } from '../../../../src/utils/relay-probe/nip11-probe'
+
+const { expect } = chai
+
+When(
+ 'a peering scope covers kinds {int} through {int}',
+ function (this: World>, from: number, to: number) {
+ this.parameters.peeringFilters = peeringScopeToFilters({ kinds: [[from, to] as any] }, 1_000, 4)
+ },
+)
+
+Then('the scope produces filters of at most {int} kinds', function (this: World>, limit: number) {
+ const filters = this.parameters.peeringFilters as SubscriptionFilter[]
+ expect(filters.length).to.be.greaterThan(0)
+ expect(filters.every((filter) => (filter.kinds?.length ?? 0) <= limit)).to.equal(true)
+})
+
+When(
+ 'a peering scope uses a {int} second rolling window at {int}',
+ function (this: World>, windowSeconds: number, now: number) {
+ this.parameters.peeringLiveFilters = peeringScopesToLiveFilters([{ kinds: [1], windowSeconds }], now, 60)
+ },
+)
+
+Then('its live filter starts at {int}', function (this: World>, since: number) {
+ const [filter] = this.parameters.peeringLiveFilters as SubscriptionFilter[]
+ expect(filter.since).to.equal(since)
+})
+
+When('an unbounded peering filter is split at {int}', function (this: World>, now: number) {
+ this.parameters.peeringSplit = splitPeeringFilter({ kinds: [1] }, now)
+})
+
+Then(
+ 'the split filters cover {int} to {int} and {int} to {int}',
+ function (
+ this: World>,
+ firstSince: number,
+ firstUntil: number,
+ secondSince: number,
+ secondUntil: number,
+ ) {
+ const [first, second] = this.parameters.peeringSplit as [SubscriptionFilter, SubscriptionFilter]
+ expect([first.since, first.until]).to.deep.equal([firstSince, firstUntil])
+ expect([second.since, second.until]).to.deep.equal([secondSince, secondUntil])
+ },
+)
+
+Then('the NIP-11 address {string} is blocked', function (this: World>, address: string) {
+ expect(isBlockedNip11Address(address)).to.equal(true)
+})
diff --git a/test/unit/app/app.spec.ts b/test/unit/app/app.spec.ts
index 7e8abb6dc..d8991a919 100644
--- a/test/unit/app/app.spec.ts
+++ b/test/unit/app/app.spec.ts
@@ -1,14 +1,12 @@
-import EventEmitter from 'events'
-
import chai from 'chai'
+import EventEmitter from 'events'
import Sinon from 'sinon'
import sinonChai from 'sinon-chai'
-
+import { Settings } from '../../../src/@types/settings'
import { App } from '../../../src/app/app'
import * as metricsTelemetry from '../../../src/telemetry/metrics'
-import * as operatorNotificationEnqueue from '../../../src/utils/operator-notification-enqueue'
import * as torClient from '../../../src/tor/client'
-import { Settings } from '../../../src/@types/settings'
+import * as operatorNotificationEnqueue from '../../../src/utils/operator-notification-enqueue'
import { SettingsStatic } from '../../../src/utils/settings'
chai.use(sinonChai)
@@ -168,7 +166,10 @@ describe('App', () => {
app.run()
- const written = fakeProcess.stdout.write.getCalls().map((call) => call.args[0]).join('')
+ const written = fakeProcess.stdout.write
+ .getCalls()
+ .map((call) => call.args[0])
+ .join('')
expect(written).to.include('███▄ █ ▒█████')
expect(written).to.match(/v\d+\.\d+\.\d+/)
@@ -196,7 +197,8 @@ describe('App', () => {
app.run()
- const lines = fakeProcess.stdout.write.getCalls()
+ const lines = fakeProcess.stdout.write
+ .getCalls()
.map((call) => String(call.args[0]).trim())
.filter(Boolean)
@@ -286,4 +288,41 @@ describe('App', () => {
expect(cluster.fork.getCalls().map((call) => call.args[0].WORKER_TYPE)).to.deep.equal(['worker', 'maintenance'])
})
+
+ it('starts one peering-sync worker when enabled peers are configured', () => {
+ const env = { ...process.env }
+ delete env.WORKER_COUNT
+ delete env.RELAY_BROADCAST_FANOUT
+ sandbox.stub(process, 'env').value(env)
+ sandbox.useFakeTimers()
+ sandbox.stub(SettingsStatic, 'watchSettings').returns([])
+ sandbox.stub(operatorNotificationEnqueue, 'enqueueOperatorNotification').resolves()
+ sandbox.stub(torClient, 'addOnion').rejects(new Error('tor is not configured'))
+ let pid = 1001
+ cluster.fork.callsFake(() => createWorker(String(pid), pid++))
+ const app = new App(
+ fakeProcess as any,
+ cluster as any,
+ sandbox.stub().returns({
+ payments: { enabled: false },
+ workers: { count: 1 },
+ eventStore: { backend: 'postgres' },
+ peering: {
+ enabled: false,
+ peers: [
+ { pubkey: 'a'.repeat(64), relayUrl: 'wss://peer.example.com', enabled: true },
+ { pubkey: 'b'.repeat(64), relayUrl: 'wss://disabled.example.com', enabled: false },
+ ],
+ },
+ } as unknown as Settings),
+ )
+
+ app.run()
+
+ expect(cluster.fork.getCalls().map((call) => call.args[0].WORKER_TYPE)).to.deep.equal([
+ 'worker',
+ 'maintenance',
+ 'peering-sync',
+ ])
+ })
})
diff --git a/test/unit/app/peering-sync-worker.spec.ts b/test/unit/app/peering-sync-worker.spec.ts
new file mode 100644
index 000000000..c97e5cbed
--- /dev/null
+++ b/test/unit/app/peering-sync-worker.spec.ts
@@ -0,0 +1,478 @@
+import { expect } from 'chai'
+import EventEmitter from 'events'
+import { Event } from '../../../src/@types/event'
+import { PeeringPeerSyncState } from '../../../src/@types/peering'
+import { IEventRepository, ReconciliationItem } from '../../../src/@types/repositories'
+import { Settings } from '../../../src/@types/settings'
+import { PeeringSyncWorker } from '../../../src/app/peering-sync-worker'
+import { getPublicKey, identifyEvent, isEventMatchingFilter, signEvent } from '../../../src/utils/event'
+import { PeeringFetchOverflowError } from '../../../src/utils/peering-connection'
+import { PeeringSyncStateStore } from '../../../src/utils/peering-sync-state'
+
+const privateKey = 'a'.repeat(64)
+const pubkey = getPublicKey(privateKey)
+const peerPubkey = getPublicKey('b'.repeat(64))
+
+const makeEvent = async (content: string, createdAt: number): Promise => {
+ const unsigned = await identifyEvent({
+ pubkey,
+ created_at: createdAt,
+ kind: 1,
+ tags: [],
+ content,
+ })
+
+ return signEvent(privateKey)(unsigned)
+}
+
+const waitFor = async (predicate: () => boolean, timeoutMs = 5_000): Promise => {
+ const startedAt = Date.now()
+ while (!predicate()) {
+ if (Date.now() - startedAt > timeoutMs) {
+ throw new Error('timed out waiting for condition')
+ }
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ }
+}
+
+class FakeConnection {
+ public liveOnEvent?: (event: Event) => void
+ public closeCalls = 0
+ private readonly closed: Promise
+ private resolveClosed: (() => void) | undefined
+
+ public constructor(private readonly remoteEvents: Event[]) {
+ this.closed = new Promise((resolve) => {
+ this.resolveClosed = resolve
+ })
+ }
+
+ public async reconcile(
+ filter: Parameters[0],
+ items: ReconciliationItem[],
+ ): Promise<{ output: null; haveIds: string[]; needIds: string[] }> {
+ const localIds = new Set(items.map((item) => Buffer.from(item.event_id).toString('hex')))
+ const remoteIds = this.remoteEvents.filter((event) => isEventMatchingFilter(filter)(event)).map((event) => event.id)
+
+ return {
+ output: null,
+ haveIds: [],
+ needIds: remoteIds.filter((id) => !localIds.has(id)),
+ }
+ }
+
+ public async fetchEvents(ids: string[]): Promise {
+ const wanted = new Set(ids)
+
+ return this.remoteEvents.filter((event) => wanted.has(event.id))
+ }
+
+ public async subscribe(_filters: unknown[], onEvent: (event: Event) => void): Promise {
+ this.liveOnEvent = onEvent
+ }
+
+ public waitForClose(): Promise {
+ return this.closed
+ }
+
+ public close(): void {
+ this.closeCalls += 1
+ this.resolveClosed?.()
+ }
+}
+
+class FakeStateStore extends PeeringSyncStateStore {
+ public readonly published: PeeringPeerSyncState[] = []
+
+ public constructor() {
+ super({} as any)
+ }
+
+ public override async publish(state: PeeringPeerSyncState): Promise {
+ this.published.push({ ...state })
+ }
+}
+
+describe('PeeringSyncWorker', () => {
+ const now = Math.floor(Date.now() / 1000)
+ let fakeProcess: EventEmitter & { exit: (...args: unknown[]) => void }
+ let connection: FakeConnection
+ let stateStore: FakeStateStore
+ let localEvents: Event[]
+ let eventRepository: IEventRepository
+ let worker: PeeringSyncWorker
+ let settingsState: Settings
+
+ beforeEach(async () => {
+ fakeProcess = Object.assign(new EventEmitter(), {
+ exit: () => {},
+ })
+ localEvents = []
+ stateStore = new FakeStateStore()
+ settingsState = {
+ info: { relay_url: 'wss://relay.example.com' },
+ peering: {
+ enabled: true,
+ peers: [{ pubkey: peerPubkey, relayUrl: 'wss://peer.example.com', enabled: true }],
+ },
+ } as Settings
+
+ eventRepository = {
+ findReconciliationItems: async (filter: Parameters[0]) =>
+ localEvents
+ .filter((event) => isEventMatchingFilter(filter)(event))
+ .map((event) => ({ event_created_at: event.created_at, event_id: Buffer.from(event.id, 'hex') })),
+ create: async (event: Event) => {
+ if (localEvents.some((local) => local.id === event.id)) {
+ return 0
+ }
+ localEvents.push(event)
+ return 1
+ },
+ createMany: async (events: Event[]) => {
+ let inserted = 0
+ for (const event of events) {
+ if (!localEvents.some((local) => local.id === event.id)) {
+ localEvents.push(event)
+ inserted += 1
+ }
+ }
+ return inserted
+ },
+ upsert: async (event: Event) => {
+ const index = localEvents.findIndex((local) => local.id === event.id)
+ if (index >= 0) {
+ if (localEvents[index].created_at < event.created_at) {
+ localEvents[index] = event
+ return 1
+ }
+ return 0
+ }
+ localEvents.push(event)
+ return 1
+ },
+ upsertMany: async (events: Event[]) => {
+ let inserted = 0
+ for (const event of events) {
+ if (!localEvents.some((local) => local.id === event.id)) {
+ localEvents.push(event)
+ inserted += 1
+ }
+ }
+ return inserted
+ },
+ deleteByPubkeyAndIds: async (pubkey: string, ids: string[]) => {
+ const before = localEvents.length
+ const remaining = localEvents.filter((event) => !(event.pubkey === pubkey && ids.includes(event.id)))
+ localEvents.splice(0, localEvents.length, ...remaining)
+ return before - localEvents.length
+ },
+ deleteByPubkeyExceptKinds: async (pubkey: string, excludedKinds: number[]) => {
+ const before = localEvents.length
+ const remaining = localEvents.filter((event) => event.pubkey !== pubkey || excludedKinds.includes(event.kind))
+ localEvents.splice(0, localEvents.length, ...remaining)
+ return before - localEvents.length
+ },
+ } as unknown as IEventRepository
+ })
+
+ afterEach(() => {
+ worker?.close()
+ })
+
+ interface StartWorkerOptions {
+ scopes?: { kinds: number[]; windowSeconds?: number }[]
+ reconcileIntervalMs?: number
+ peerRefreshMs?: number
+ stateHeartbeatMs?: number
+ configureConnection?: (connection: FakeConnection) => void
+ userRepository?: {
+ isVanished: (pubkey: string) => Promise
+ setVanished: (pubkey: string) => Promise
+ }
+ }
+
+ const startWorker = (remoteEvents: Event[], options: StartWorkerOptions = {}): void => {
+ connection = new FakeConnection(remoteEvents)
+ options.configureConnection?.(connection)
+
+ worker = new PeeringSyncWorker(fakeProcess as unknown as NodeJS.Process, () => settingsState, eventRepository, {
+ authenticate: async () => ({ socket: { close: () => {} }, challenge: 'c', authEvent: {} }) as any,
+ fetchAdvertisement: async () => ({
+ enabled: true,
+ supportedNips: [1, 77],
+ self: peerPubkey,
+ terms: [],
+ scopes: options.scopes ?? [{ kinds: [1] }],
+ }),
+ createConnection: () => connection as any,
+ stateStore,
+ privateKey: () => privateKey,
+ reconcileIntervalMs: options.reconcileIntervalMs ?? 60_000,
+ peerRefreshMs: options.peerRefreshMs,
+ stateHeartbeatMs: options.stateHeartbeatMs,
+ reconnectDelayMs: 10,
+ userRepository: options.userRepository,
+ })
+
+ worker.run()
+ }
+
+ it('converges an empty store from negentropy and then applies live events', async () => {
+ const current = Math.floor(Date.now() / 1000)
+ const historical = await makeEvent('historical', current - 30)
+ startWorker([historical])
+
+ await waitFor(() => localEvents.length === 1)
+ expect(localEvents[0].id).to.equal(historical.id)
+ expect(connection.liveOnEvent).to.be.a('function')
+
+ await waitFor(() => stateStore.published.some((state) => state.status === 'live'))
+ const liveState = stateStore.published.filter((state) => state.status === 'live').at(-1)
+ expect(liveState?.lastReconciledAt).to.be.a('string')
+ expect(liveState?.missingEvents).to.equal(1)
+ expect(liveState?.receivedEvents).to.equal(1)
+
+ const live = await makeEvent('live', current)
+ connection.liveOnEvent?.(live)
+
+ await waitFor(() => localEvents.length === 2)
+ expect(localEvents.map((event) => event.id)).to.include(live.id)
+
+ const older = await makeEvent('older overlap replay', current - 20)
+ connection.liveOnEvent?.(older)
+
+ await waitFor(() => localEvents.length === 3)
+ await waitFor(() => stateStore.published.at(-1)?.latestEventCreatedAt === live.created_at)
+ const lag = stateStore.published.at(-1)?.lagSeconds ?? Infinity
+ expect(lag).to.be.at.least(0)
+ expect(lag).to.be.at.most(5)
+ })
+
+ it('delivers live events while history reconciliation is still running', async () => {
+ let releaseReconcile: (() => void) | undefined
+ const reconcileGate = new Promise((resolve) => {
+ releaseReconcile = resolve
+ })
+
+ startWorker([], {
+ configureConnection: (fake) => {
+ const reconcile = fake.reconcile.bind(fake)
+ fake.reconcile = async (filter, items) => {
+ await reconcileGate
+ return reconcile(filter as any, items)
+ }
+ },
+ })
+
+ await waitFor(() => connection.liveOnEvent !== undefined)
+
+ const live = await makeEvent('during history sync', Math.floor(Date.now() / 1000))
+ connection.liveOnEvent?.(live)
+
+ await waitFor(() => localEvents.length === 1)
+ expect(localEvents[0].id).to.equal(live.id)
+ releaseReconcile?.()
+ })
+
+ it('republishes sync state while a long reconciliation is running', async () => {
+ let releaseReconcile: (() => void) | undefined
+ const reconcileGate = new Promise((resolve) => {
+ releaseReconcile = resolve
+ })
+
+ startWorker([], {
+ stateHeartbeatMs: 20,
+ configureConnection: (fake) => {
+ const reconcile = fake.reconcile.bind(fake)
+ fake.reconcile = async (filter, items) => {
+ await reconcileGate
+ return reconcile(filter as any, items)
+ }
+ },
+ })
+
+ await waitFor(() => stateStore.published.filter((state) => state.status === 'reconciling').length >= 2)
+ releaseReconcile?.()
+ })
+
+ it('advances lag on state heartbeats between reconciliations', async () => {
+ const head = await makeEvent('head', Math.floor(Date.now() / 1000))
+ startWorker([head], { stateHeartbeatMs: 20 })
+
+ await waitFor(() => localEvents.length === 1)
+ await waitFor(() => stateStore.published.some((state) => (state.lagSeconds ?? 0) >= 1), 4_000)
+ })
+
+ it('retries an overflowing fetch batch in smaller pieces', async () => {
+ const first = await makeEvent('first', now - 30)
+ const second = await makeEvent('second', now - 20)
+ const fetchSizes: number[] = []
+
+ startWorker([first, second], {
+ configureConnection: (fake) => {
+ const fetchEvents = fake.fetchEvents.bind(fake)
+ fake.fetchEvents = async (ids) => {
+ fetchSizes.push(ids.length)
+ if (ids.length > 1) {
+ throw new PeeringFetchOverflowError()
+ }
+ return fetchEvents(ids)
+ }
+ },
+ })
+
+ await waitFor(() => localEvents.length === 2)
+ expect(fetchSizes).to.deep.equal([2, 1, 1])
+ })
+
+ it('re-checks live events against scope bounds, not the subscription overlap', async () => {
+ startWorker([])
+
+ await waitFor(() => connection.liveOnEvent !== undefined)
+
+ const late = await makeEvent('late publish', Math.floor(Date.now() / 1000) - 120)
+ connection.liveOnEvent?.(late)
+
+ await waitFor(() => localEvents.length === 1)
+ expect(localEvents[0].id).to.equal(late.id)
+ })
+
+ it('splits a scope the peer rejects as too large', async () => {
+ const historical = await makeEvent('historical', now - 30)
+ let calls = 0
+
+ startWorker([historical], {
+ configureConnection: (fake) => {
+ const reconcile = fake.reconcile.bind(fake)
+ fake.reconcile = async (filter, items) => {
+ calls += 1
+ if (calls === 1) {
+ throw new Error('blocked: this query is too big')
+ }
+ return reconcile(filter as any, items)
+ }
+ },
+ })
+
+ await waitFor(() => localEvents.length === 1)
+ expect(localEvents[0].id).to.equal(historical.id)
+ expect(calls).to.be.greaterThan(2)
+ await waitFor(() => stateStore.published.some((state) => state.status === 'live'))
+ expect(stateStore.published.filter((state) => state.status === 'live').at(-1)?.missingEvents).to.equal(1)
+ })
+
+ it('does not count a batch whose write stored nothing', async () => {
+ const historical = await makeEvent('historical', now - 30)
+ eventRepository.createMany = async () => 0
+ eventRepository.upsertMany = async () => 0
+ eventRepository.create = async () => 0
+ eventRepository.upsert = async () => 0
+
+ startWorker([historical])
+
+ await waitFor(() => stateStore.published.some((state) => state.status === 'live'))
+ const liveState = stateStore.published.filter((state) => state.status === 'live').at(-1)
+ expect(localEvents).to.have.length(0)
+ expect(liveState?.receivedEvents).to.equal(0)
+ })
+
+ it('applies the rolling window to live events using the current time', async () => {
+ startWorker([], { scopes: [{ kinds: [1], windowSeconds: 60 }] })
+
+ await waitFor(() => connection.liveOnEvent !== undefined)
+
+ const current = Math.floor(Date.now() / 1000)
+ const stale = await makeEvent('outside window', current - 120)
+ connection.liveOnEvent?.(stale)
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ expect(localEvents).to.have.length(0)
+
+ const fresh = await makeEvent('inside window', current)
+ connection.liveOnEvent?.(fresh)
+ await waitFor(() => localEvents.length === 1)
+ expect(localEvents[0].id).to.equal(fresh.id)
+ })
+
+ it('stops a session when its peer is disabled in reloaded settings', async () => {
+ startWorker([], { peerRefreshMs: 20 })
+ await waitFor(() => connection.liveOnEvent !== undefined)
+
+ settingsState = {
+ ...settingsState,
+ peering: {
+ enabled: true,
+ peers: [],
+ },
+ } as Settings
+
+ await waitFor(() => connection.closeCalls > 0)
+ })
+
+ it('skips events from vanished authors', async () => {
+ const event = await makeEvent('from vanished author', now)
+ startWorker([event], {
+ userRepository: {
+ isVanished: async () => true,
+ setVanished: async () => 1,
+ },
+ })
+
+ await waitFor(() => stateStore.published.some((state) => state.status === 'live'))
+ expect(localEvents).to.have.length(0)
+ expect(stateStore.published.filter((state) => state.status === 'live').at(-1)?.receivedEvents).to.equal(0)
+ })
+
+ it('records a periodic reconciliation failure in peer state', async () => {
+ let calls = 0
+
+ startWorker([], {
+ reconcileIntervalMs: 500,
+ configureConnection: (fake) => {
+ const reconcile = fake.reconcile.bind(fake)
+ fake.reconcile = async (filter, items) => {
+ calls += 1
+ if (calls > 1) {
+ throw new Error('negentropy timeout')
+ }
+ return reconcile(filter as any, items)
+ }
+ },
+ })
+
+ await waitFor(() => stateStore.published.some((state) => state.status === 'live'))
+ await waitFor(() =>
+ stateStore.published.some((state) => state.status === 'error' && state.lastError === 'negentropy timeout'),
+ )
+ })
+
+ it('records an error when the peer has no peering advertisement', async () => {
+ connection = new FakeConnection([])
+ const settings = {
+ info: { relay_url: 'wss://relay.example.com' },
+ peering: {
+ enabled: true,
+ peers: [{ pubkey: peerPubkey, relayUrl: 'wss://peer.example.com', enabled: true }],
+ },
+ } as Settings
+
+ worker = new PeeringSyncWorker(fakeProcess as unknown as NodeJS.Process, () => settings, eventRepository, {
+ authenticate: async () => ({ socket: { close: () => {} }, challenge: 'c', authEvent: {} }) as any,
+ fetchAdvertisement: async () => ({
+ enabled: false,
+ supportedNips: [1],
+ scopes: [],
+ terms: [],
+ }),
+ createConnection: () => connection as any,
+ stateStore,
+ privateKey: () => privateKey,
+ reconnectDelayMs: 1_000,
+ })
+
+ worker.run()
+
+ await waitFor(() => stateStore.published.some((state) => state.status === 'error'))
+ expect(stateStore.published.some((state) => state.lastError === 'peer does not advertise peering')).to.equal(true)
+ })
+})
diff --git a/test/unit/controllers/admin/get-admin-peering-controller.spec.ts b/test/unit/controllers/admin/get-admin-peering-controller.spec.ts
new file mode 100644
index 000000000..3abcd102f
--- /dev/null
+++ b/test/unit/controllers/admin/get-admin-peering-controller.spec.ts
@@ -0,0 +1,50 @@
+import chai from 'chai'
+import Sinon from 'sinon'
+import sinonChai from 'sinon-chai'
+
+import type { PeeringPeerSyncState } from '../../../../src/@types/peering'
+import type { Settings } from '../../../../src/@types/settings'
+import { GetAdminPeeringController } from '../../../../src/controllers/admin/get-admin-peering-controller'
+
+chai.use(sinonChai)
+
+const { expect } = chai
+
+describe('GetAdminPeeringController', () => {
+ it('merges configured peers with stored sync state', async () => {
+ const state: PeeringPeerSyncState = {
+ pubkey: 'a'.repeat(64),
+ relayUrl: 'wss://peer.example.com',
+ status: 'live',
+ scopeCount: 1,
+ updatedAt: new Date().toISOString(),
+ lastReconciledAt: new Date().toISOString(),
+ lagSeconds: 3,
+ }
+ const stateStore = { list: Sinon.stub().resolves([state]) }
+ const settings = {
+ peering: {
+ enabled: true,
+ peers: [{ pubkey: state.pubkey, relayUrl: state.relayUrl }],
+ },
+ } as Settings
+ const controller = new GetAdminPeeringController(stateStore as any, () => settings)
+ const response = {
+ status: Sinon.stub().returnsThis(),
+ setHeader: Sinon.stub().returnsThis(),
+ send: Sinon.stub(),
+ }
+
+ await controller.handleRequest({} as any, response as any)
+
+ expect(response.status).to.have.been.calledWith(200)
+ const payload = response.send.firstCall.args[0]
+ expect(payload.peers).to.have.length(1)
+ expect(payload.peers[0]).to.include({
+ configured: true,
+ enabled: true,
+ stale: false,
+ })
+ expect(payload.peers[0].state.lagSeconds).to.equal(3)
+ })
+})
diff --git a/test/unit/factories/controllers/get-admin-peering-controller-factory.spec.ts b/test/unit/factories/controllers/get-admin-peering-controller-factory.spec.ts
new file mode 100644
index 000000000..44f48f374
--- /dev/null
+++ b/test/unit/factories/controllers/get-admin-peering-controller-factory.spec.ts
@@ -0,0 +1,32 @@
+import { expect } from 'chai'
+import Sinon from 'sinon'
+
+import * as cacheClient from '../../../../src/cache/client'
+import { createGetAdminPeeringController } from '../../../../src/factories/controllers/get-admin-peering-controller-factory'
+
+describe('get-admin-peering-controller factory', () => {
+ let sandbox: Sinon.SinonSandbox
+
+ beforeEach(() => {
+ sandbox = Sinon.createSandbox()
+ const client = {
+ isOpen: true,
+ on: function () {
+ return client
+ },
+ }
+ sandbox.stub(cacheClient, 'getCacheClient').returns(client as any)
+ })
+
+ afterEach(() => {
+ sandbox.restore()
+ })
+
+ it('creates one controller and reuses it across dashboard requests', () => {
+ const first = createGetAdminPeeringController()
+ const second = createGetAdminPeeringController()
+
+ expect(first).to.have.property('handleRequest')
+ expect(second).to.equal(first)
+ })
+})
diff --git a/test/unit/services/event-import-service.spec.ts b/test/unit/services/event-import-service.spec.ts
index 8aa91870f..7806f7a64 100644
--- a/test/unit/services/event-import-service.spec.ts
+++ b/test/unit/services/event-import-service.spec.ts
@@ -1,18 +1,17 @@
-import { join } from 'path'
+import { expect } from 'chai'
import fs from 'fs'
import os from 'os'
+import { join } from 'path'
import { Readable } from 'stream'
-
+import { Event } from '../../../src/@types/event'
+import { EventDeduplicationMetadataKey, EventKinds, EventTags } from '../../../src/constants/base'
import {
createEventBatchPersister,
EventImportLineError,
EventImportService,
EventImportStats,
} from '../../../src/services/event-import-service'
-import { EventDeduplicationMetadataKey, EventKinds, EventTags } from '../../../src/constants/base'
-import { Event } from '../../../src/@types/event'
-import { expect } from 'chai'
import { getEvents } from '../data/events'
describe('EventImportService', () => {
@@ -393,4 +392,201 @@ describe('EventImportService', () => {
expect(upsertedEvents).to.have.length(1)
expect((upsertedEvents[0] as any)[EventDeduplicationMetadataKey]).to.deep.equal(['one'])
})
+
+ it('skips vanished authors and applies kind-62 vanish rules when a user repository is configured', async () => {
+ const visible: Event = {
+ id: 'a'.repeat(64),
+ pubkey: 'b'.repeat(64),
+ created_at: 1,
+ kind: 1,
+ tags: [],
+ content: 'visible',
+ sig: 'c'.repeat(128),
+ }
+ const vanished: Event = {
+ ...visible,
+ id: 'd'.repeat(64),
+ pubkey: 'e'.repeat(64),
+ }
+ const vanish: Event = {
+ ...visible,
+ id: 'f'.repeat(64),
+ pubkey: '1'.repeat(64),
+ kind: EventKinds.REQUEST_TO_VANISH,
+ content: 'vanish',
+ }
+
+ const created: Event[] = []
+ const deletedExcept: string[][] = []
+ const vanishedPubkeys: string[] = []
+
+ const eventRepository = {
+ create: async (event: Event) => {
+ created.push(event)
+ return 1
+ },
+ createMany: async (events: Event[]) => {
+ created.push(...events)
+ return events.length
+ },
+ upsert: async () => 0,
+ upsertMany: async () => 0,
+ deleteByPubkeyAndIds: async () => 0,
+ deleteByPubkeyExceptKinds: async (pubkey: string, kinds: number[]) => {
+ deletedExcept.push([pubkey, ...kinds.map(String)])
+ return 1
+ },
+ } as any
+
+ const userRepository = {
+ isVanished: async (pubkey: string) => pubkey === vanished.pubkey,
+ setVanished: async (pubkey: string) => {
+ vanishedPubkeys.push(pubkey)
+ return 1
+ },
+ }
+
+ const persistBatch = createEventBatchPersister(eventRepository, { userRepository })
+ const inserted = await persistBatch([visible, vanished, vanish])
+
+ expect(inserted).to.equal(2)
+ expect(created.map((event) => event.id)).to.deep.equal([visible.id, vanish.id])
+ expect(deletedExcept).to.deep.equal([[vanish.pubkey, String(EventKinds.REQUEST_TO_VANISH)]])
+ expect(vanishedPubkeys).to.deep.equal([vanish.pubkey])
+ })
+
+ it('checks vanish state once per author within a batch', async () => {
+ const first: Event = {
+ id: 'a'.repeat(64),
+ pubkey: 'b'.repeat(64),
+ created_at: 1,
+ kind: 1,
+ tags: [],
+ content: 'one',
+ sig: 'c'.repeat(128),
+ }
+ const second: Event = { ...first, id: 'd'.repeat(64), content: 'two' }
+
+ let checks = 0
+ const eventRepository = {
+ create: async () => 1,
+ createMany: async (events: Event[]) => events.length,
+ upsert: async () => 0,
+ upsertMany: async (events: Event[]) => events.length,
+ deleteByPubkeyAndIds: async () => 0,
+ } as any
+
+ const persistBatch = createEventBatchPersister(eventRepository, {
+ userRepository: {
+ isVanished: async () => {
+ checks += 1
+ return false
+ },
+ setVanished: async () => 1,
+ },
+ })
+
+ const inserted = await persistBatch([first, second])
+
+ expect(inserted).to.equal(2)
+ expect(checks).to.equal(1)
+ })
+
+ it('does not apply a kind-62 request aimed at another relay', async () => {
+ const vanish: Event = {
+ id: 'a'.repeat(64),
+ pubkey: 'b'.repeat(64),
+ created_at: 1,
+ kind: EventKinds.REQUEST_TO_VANISH,
+ tags: [[EventTags.Relay, 'wss://other.example.com']],
+ content: 'vanish',
+ sig: 'c'.repeat(128),
+ }
+ const eventRepository = {
+ create: async () => 1,
+ createMany: async (events: Event[]) => events.length,
+ upsert: async () => 0,
+ upsertMany: async () => 0,
+ deleteByPubkeyAndIds: async () => 0,
+ deleteByPubkeyExceptKinds: async () => {
+ throw new Error('must not delete for another relay')
+ },
+ } as any
+ let vanished = 0
+ const persistBatch = createEventBatchPersister(eventRepository, {
+ relayUrl: 'wss://relay.example.com',
+ userRepository: {
+ isVanished: async () => false,
+ setVanished: async () => {
+ vanished += 1
+ return 1
+ },
+ },
+ })
+
+ const inserted = await persistBatch([vanish])
+
+ expect(inserted).to.equal(1)
+ expect(vanished).to.equal(0)
+ })
+
+ it('keeps import behavior for kind-62 events when no user repository is supplied', async () => {
+ const vanish: Event = {
+ id: 'a'.repeat(64),
+ pubkey: 'b'.repeat(64),
+ created_at: 1,
+ kind: EventKinds.REQUEST_TO_VANISH,
+ tags: [[EventTags.Relay, 'wss://other.example.com']],
+ content: 'vanish',
+ sig: 'c'.repeat(128),
+ }
+ const eventRepository = {
+ create: async () => 1,
+ createMany: async (events: Event[]) => events.length,
+ upsert: async () => 0,
+ upsertMany: async () => 0,
+ deleteByPubkeyAndIds: async () => 0,
+ deleteByPubkeyExceptKinds: async () => {
+ throw new Error('import must not apply vanish deletions')
+ },
+ } as any
+
+ const persistBatch = createEventBatchPersister(eventRepository)
+ const inserted = await persistBatch([vanish])
+
+ expect(inserted).to.equal(1)
+ })
+
+ it('drops ephemeral events without counting them as inserted', async () => {
+ const ephemeral: Event = {
+ id: 'a'.repeat(64),
+ pubkey: 'b'.repeat(64),
+ created_at: 1,
+ kind: EventKinds.EPHEMERAL_FIRST,
+ tags: [],
+ content: 'ephemeral',
+ sig: 'c'.repeat(128),
+ }
+
+ let createdManyCalls = 0
+ const eventRepository = {
+ create: async () => 1,
+ createMany: async (events: Event[]) => {
+ if (!events.length) {
+ return 0
+ }
+ createdManyCalls += 1
+ return events.length
+ },
+ upsert: async () => 0,
+ upsertMany: async (events: Event[]) => events.length,
+ deleteByPubkeyAndIds: async () => 0,
+ } as any
+
+ const persistBatch = createEventBatchPersister(eventRepository)
+ const inserted = await persistBatch([ephemeral])
+
+ expect(inserted).to.equal(0)
+ expect(createdManyCalls).to.equal(0)
+ })
})
diff --git a/test/unit/utils/admin-ops-config.spec.ts b/test/unit/utils/admin-ops-config.spec.ts
index 7694db13f..fbad276cf 100644
--- a/test/unit/utils/admin-ops-config.spec.ts
+++ b/test/unit/utils/admin-ops-config.spec.ts
@@ -1,15 +1,11 @@
import { expect } from 'chai'
-
-import { listConfiguredWorkers, mergeWorkerRows } from '../../../src/utils/admin-ops-config'
import { Settings } from '../../../src/@types/settings'
+import { listConfiguredWorkers, mergeWorkerRows } from '../../../src/utils/admin-ops-config'
import { ADMIN_OPS_HEARTBEAT_TTL_SECONDS, isAdminOpsHeartbeatStale } from '../../../src/utils/admin-ops-store'
describe('admin-ops-config', () => {
it('marks unmonitored workers as not stale without a heartbeat', () => {
- const rows = mergeWorkerRows(
- [{ type: 'worker', index: '0', enabled: true, label: 'Client worker 0' }],
- [],
- )
+ const rows = mergeWorkerRows([{ type: 'worker', index: '0', enabled: true, label: 'Client worker 0' }], [])
expect(rows[0].monitored).to.equal(false)
expect(rows[0].stale).to.equal(false)
@@ -40,4 +36,16 @@ describe('admin-ops-config', () => {
expect(workers.map((worker) => worker.type)).to.deep.equal(['worker', 'maintenance'])
})
+
+ it('lists the peering-sync worker when enabled peers are configured', () => {
+ const workers = listConfiguredWorkers({
+ workers: { count: 1 },
+ peering: {
+ enabled: false,
+ peers: [{ pubkey: 'a'.repeat(64), relayUrl: 'wss://peer.example.com' }],
+ },
+ } as unknown as Settings)
+
+ expect(workers.map((worker) => worker.type)).to.deep.equal(['worker', 'maintenance', 'peering-sync'])
+ })
})
diff --git a/test/unit/utils/admin-peering.spec.ts b/test/unit/utils/admin-peering.spec.ts
new file mode 100644
index 000000000..a5bf6e080
--- /dev/null
+++ b/test/unit/utils/admin-peering.spec.ts
@@ -0,0 +1,75 @@
+import { expect } from 'chai'
+import { PeeringPeerSyncState } from '../../../src/@types/peering'
+import { Settings } from '../../../src/@types/settings'
+import { mergePeeringRows } from '../../../src/utils/admin-peering'
+
+const state = (overrides: Partial = {}): PeeringPeerSyncState => ({
+ pubkey: 'a'.repeat(64),
+ relayUrl: 'wss://peer.example.com',
+ status: 'live',
+ scopeCount: 1,
+ updatedAt: new Date().toISOString(),
+ ...overrides,
+})
+
+describe('admin peering rows', () => {
+ it('merges configured peers with their stored sync state', () => {
+ const rows = mergePeeringRows(
+ {
+ peering: {
+ enabled: true,
+ peers: [{ pubkey: 'a'.repeat(64), relayUrl: 'wss://peer.example.com' }],
+ },
+ } as Settings,
+ [state()],
+ )
+
+ expect(rows).to.have.length(1)
+ expect(rows[0]).to.include({
+ configured: true,
+ enabled: true,
+ stale: false,
+ })
+ expect(rows[0].state?.status).to.equal('live')
+ })
+
+ it('marks an enabled peer stale when it has no state or the state is old', () => {
+ const rows = mergePeeringRows(
+ {
+ peering: {
+ enabled: true,
+ peers: [
+ { pubkey: 'a'.repeat(64), relayUrl: 'wss://new.example.com' },
+ { pubkey: 'b'.repeat(64), relayUrl: 'wss://old.example.com' },
+ ],
+ },
+ } as Settings,
+ [state({ pubkey: 'b'.repeat(64), updatedAt: '2020-01-01T00:00:00.000Z' })],
+ )
+
+ expect(rows.find((row) => row.relayUrl === 'wss://new.example.com')?.stale).to.equal(true)
+ expect(rows.find((row) => row.relayUrl === 'wss://old.example.com')?.stale).to.equal(true)
+ })
+
+ it('shows stored state for a peer that was removed from settings', () => {
+ const rows = mergePeeringRows({} as Settings, [state({ pubkey: 'c'.repeat(64) })])
+
+ expect(rows[0]).to.include({ configured: false, enabled: false })
+ })
+
+ it('does not attach state from an old relay URL to the configured peer', () => {
+ const rows = mergePeeringRows(
+ {
+ peering: {
+ enabled: true,
+ peers: [{ pubkey: 'a'.repeat(64), relayUrl: 'wss://new.example.com' }],
+ },
+ } as Settings,
+ [state({ relayUrl: 'wss://old.example.com' })],
+ )
+
+ expect(rows).to.have.length(1)
+ expect(rows[0].state).to.equal(undefined)
+ expect(rows[0].stale).to.equal(true)
+ })
+})
diff --git a/test/unit/utils/event.spec.ts b/test/unit/utils/event.spec.ts
index ec6d49723..9146199ab 100644
--- a/test/unit/utils/event.spec.ts
+++ b/test/unit/utils/event.spec.ts
@@ -1,5 +1,6 @@
-import { ALL_RELAYS, EventKinds, EventTags } from '../../../src/constants/base'
+import { expect } from 'chai'
import { CanonicalEvent, Event } from '../../../src/@types/event'
+import { ALL_RELAYS, EventKinds, EventTags } from '../../../src/constants/base'
import {
getEventExpiration,
isContactListEvent,
@@ -22,7 +23,6 @@ import {
isWelcomeRumorEvent,
serializeEvent,
} from '../../../src/utils/event'
-import { expect } from 'chai'
describe('NIP-01', () => {
describe('serializeEvent', () => {
@@ -661,6 +661,28 @@ describe('NIP-62', () => {
expect(isRequestToVanishEvent(event, 'relay_url')).to.be.false
})
+ it('returns true when any of several relay tags targets this relay', () => {
+ const event: Event = {
+ kind: 62,
+ tags: [
+ [EventTags.Relay, 'other_relay_url'],
+ [EventTags.Relay, 'relay_url'],
+ ],
+ } as any
+ expect(isRequestToVanishEvent(event, 'relay_url')).to.be.true
+ })
+
+ it('returns true when any of several relay tags is ALL_RELAYS', () => {
+ const event: Event = {
+ kind: 62,
+ tags: [
+ [EventTags.Relay, 'other_relay_url'],
+ [EventTags.Relay, ALL_RELAYS],
+ ],
+ } as any
+ expect(isRequestToVanishEvent(event, 'relay_url')).to.be.true
+ })
+
it('returns false when relay URL is provided for non-kind-62 event', () => {
const event: Event = {
kind: 1,
diff --git a/test/unit/utils/peering-advertisement.spec.ts b/test/unit/utils/peering-advertisement.spec.ts
new file mode 100644
index 000000000..bb7ca47e0
--- /dev/null
+++ b/test/unit/utils/peering-advertisement.spec.ts
@@ -0,0 +1,62 @@
+import { expect } from 'chai'
+
+import { fetchPeeringAdvertisement, parsePeeringAdvertisement } from '../../../src/utils/peering-advertisement'
+
+describe('peering advertisement', () => {
+ it('reads scopes and terms from the NIP-11 peering section', () => {
+ const advertisement = parsePeeringAdvertisement({
+ self: 'A'.repeat(64),
+ supported_nips: [1, 11, 77],
+ peering: {
+ enabled: true,
+ scopes: [
+ { kinds: [1, [6, 7]], since: 100 },
+ { kinds: [30023], window_seconds: 3600 },
+ ],
+ terms: [{ amount: 1000, unit: 'msats', period: 2592000 }],
+ },
+ })
+
+ expect(advertisement).to.deep.equal({
+ enabled: true,
+ self: 'a'.repeat(64),
+ supportedNips: [1, 11, 77],
+ scopes: [
+ { kinds: [1, [6, 7]], since: 100 },
+ { kinds: [30023], windowSeconds: 3600 },
+ ],
+ terms: [{ amount: 1000, unit: 'msats', period: 2592000 }],
+ })
+ })
+
+ it('reports peering as disabled when the section is absent', () => {
+ const advertisement = parsePeeringAdvertisement({ supported_nips: [1] })
+
+ expect(advertisement.enabled).to.equal(false)
+ expect(advertisement.scopes).to.deep.equal([])
+ })
+
+ it('rejects an enabled advertisement with no scopes', () => {
+ expect(() => parsePeeringAdvertisement({ peering: { enabled: true, scopes: [] } })).to.throw('offers no scopes')
+ })
+
+ it('rejects a scope that combines a window with an absolute range', () => {
+ expect(() =>
+ parsePeeringAdvertisement({
+ peering: {
+ enabled: true,
+ scopes: [{ kinds: [1], since: 100, window_seconds: 3600 }],
+ },
+ }),
+ ).to.throw('window_seconds cannot be combined')
+ })
+
+ it('refuses to fetch an internal NIP-11 target', async () => {
+ const error = await fetchPeeringAdvertisement('ws://127.0.0.1:8008').then(
+ () => undefined,
+ (err: Error) => err,
+ )
+
+ expect(error?.message).to.include('refused unsafe NIP-11 fetch target')
+ })
+})
diff --git a/test/unit/utils/peering-connection.spec.ts b/test/unit/utils/peering-connection.spec.ts
new file mode 100644
index 000000000..3a321153f
--- /dev/null
+++ b/test/unit/utils/peering-connection.spec.ts
@@ -0,0 +1,186 @@
+import { expect } from 'chai'
+import EventEmitter from 'events'
+import { Negentropy, NegentropyStorageVector } from '../../../src/utils/negentropy'
+import { PeeringConnection, PeeringFetchOverflowError } from '../../../src/utils/peering-connection'
+
+class FakeSocket extends EventEmitter {
+ public readonly sent: unknown[][] = []
+ public closed = false
+ private responder?: Negentropy
+ private readonly remoteEvents: {
+ id: string
+ created_at: number
+ content: string
+ pubkey: string
+ kind: number
+ tags: string[][]
+ sig: string
+ [key: string]: unknown
+ }[]
+
+ public constructor(
+ remoteItems: { timestamp: number; id: Uint8Array }[],
+ remoteEvents: FakeSocket['remoteEvents'] = [],
+ ) {
+ super()
+ const storage = new NegentropyStorageVector()
+ for (const item of remoteItems) {
+ storage.insert(item.timestamp, item.id)
+ }
+ storage.seal()
+ this.responder = new Negentropy(storage)
+ this.remoteEvents = remoteEvents
+ }
+
+ public send(raw: string): void {
+ const message = JSON.parse(raw) as unknown[]
+ this.sent.push(message)
+
+ switch (message[0]) {
+ case 'NEG-OPEN':
+ case 'NEG-MSG': {
+ const neg = this.responder as Negentropy
+ const { output } = neg.reconcile(Buffer.from((message[3] as string) ?? (message[2] as string), 'hex'))
+ if (output) {
+ this.deliver(['NEG-MSG', message[1], Buffer.from(output).toString('hex')])
+ }
+ return
+ }
+ case 'REQ': {
+ const subscriptionId = message[1]
+ for (const event of this.remoteEvents) {
+ this.deliver(['EVENT', subscriptionId, event])
+ }
+ this.deliver(['EOSE', subscriptionId])
+ return
+ }
+ case 'CLOSE':
+ case 'NEG-CLOSE':
+ return
+ default:
+ return
+ }
+ }
+
+ public close(): void {
+ this.closed = true
+ this.emit('close')
+ }
+
+ public deliver(message: unknown[]): void {
+ this.emit('message', Buffer.from(JSON.stringify(message)))
+ }
+}
+
+const id = (byte: number): Uint8Array => Uint8Array.from({ length: 32 }, () => byte)
+
+describe('PeeringConnection', () => {
+ it('reconciles ids in both directions over NEG-OPEN/NEG-MSG', async () => {
+ const localId = id(1)
+ const remoteId = id(2)
+ const socket = new FakeSocket([{ timestamp: 10, id: remoteId }])
+ const connection = new PeeringConnection(socket as any)
+
+ const result = await connection.reconcile(
+ { kinds: [1] },
+ [{ event_created_at: 5, event_id: Buffer.from(localId) }],
+ 1_000,
+ )
+
+ expect(result.needIds).to.deep.equal([Buffer.from(remoteId).toString('hex')])
+ expect(result.haveIds).to.deep.equal([Buffer.from(localId).toString('hex')])
+ expect(socket.sent.some((message) => message[0] === 'NEG-OPEN')).to.equal(true)
+ expect(socket.sent.some((message) => message[0] === 'NEG-CLOSE')).to.equal(true)
+ })
+
+ it('fetches requested events and resolves on EOSE', async () => {
+ const remoteId = id(2)
+ const event = {
+ id: Buffer.from(remoteId).toString('hex'),
+ pubkey: 'f'.repeat(64),
+ created_at: 10,
+ kind: 1,
+ tags: [],
+ content: 'hello',
+ sig: 'a'.repeat(128),
+ }
+ const socket = new FakeSocket([], [event])
+ const connection = new PeeringConnection(socket as any)
+
+ const events = await connection.fetchEvents([event.id], 1_000)
+
+ expect(events).to.deep.equal([event])
+ })
+
+ it('ignores unrequested and duplicate fetch replies', async () => {
+ const wanted = {
+ id: 'b'.repeat(64),
+ pubkey: 'f'.repeat(64),
+ created_at: 10,
+ kind: 1,
+ tags: [],
+ content: 'wanted',
+ sig: 'a'.repeat(128),
+ }
+ const extra = { ...wanted, id: 'c'.repeat(64), content: 'extra' }
+ const socket = new FakeSocket([], [wanted, wanted, extra])
+ const connection = new PeeringConnection(socket as any)
+
+ const events = await connection.fetchEvents([wanted.id], 1_000)
+
+ expect(events).to.deep.equal([wanted])
+ })
+
+ it('keeps a live subscription open after EOSE', async () => {
+ const socket = new FakeSocket([])
+ const connection = new PeeringConnection(socket as any)
+ const received: unknown[] = []
+
+ await connection.subscribe([{ kinds: [1] }], (event) => received.push(event), 1_000)
+
+ socket.deliver(['EVENT', socket.sent.find((message) => message[0] === 'REQ')?.[1], { id: 'a'.repeat(64), kind: 1 }])
+
+ expect(received).to.have.length(1)
+ })
+
+ it('rejects a fetch that exceeds its byte budget without closing the connection', async () => {
+ const event = {
+ id: 'b'.repeat(64),
+ pubkey: 'f'.repeat(64),
+ created_at: 10,
+ kind: 1,
+ tags: [],
+ content: 'x'.repeat(1_000),
+ sig: 'a'.repeat(128),
+ }
+ const socket = new FakeSocket([], [event])
+ const connection = new PeeringConnection(socket as any)
+
+ const error = await connection.fetchEvents([event.id], 1_000, 100).then(
+ () => undefined,
+ (err: Error) => err,
+ )
+
+ expect(error).to.be.instanceOf(PeeringFetchOverflowError)
+ expect(socket.closed).to.equal(false)
+
+ const events = await connection.fetchEvents([event.id], 1_000)
+ expect(events).to.deep.equal([event])
+ })
+
+ it('rejects when the peer returns NEG-ERR', async () => {
+ const socket = new FakeSocket([])
+ const connection = new PeeringConnection(socket as any)
+ socket.send = function (raw: string): void {
+ const message = JSON.parse(raw) as unknown[]
+ this.deliver(['NEG-ERR', message[1], 'blocked: too big'])
+ }
+
+ const error = await connection.reconcile({ kinds: [1] }, [], 1_000).then(
+ () => undefined,
+ (err: Error) => err,
+ )
+
+ expect(error?.message).to.equal('blocked: too big')
+ })
+})
diff --git a/test/unit/utils/peering-scope.spec.ts b/test/unit/utils/peering-scope.spec.ts
new file mode 100644
index 000000000..484e4fb5d
--- /dev/null
+++ b/test/unit/utils/peering-scope.spec.ts
@@ -0,0 +1,83 @@
+import { expect } from 'chai'
+
+import {
+ expandPeeringKinds,
+ peeringScopesToLiveFilters,
+ peeringScopeToFilter,
+ peeringScopeToFilters,
+ splitPeeringFilter,
+} from '../../../src/utils/peering-scope'
+
+describe('peering scope filters', () => {
+ it('expands kind ranges into the explicit list NIP-01 filters require', () => {
+ expect(expandPeeringKinds([1, [3, 5], 4])).to.deep.equal([1, 3, 4, 5])
+ })
+
+ it('turns an absolute scope into a filter without a limit', () => {
+ expect(peeringScopeToFilter({ kinds: [1, [6, 7]], since: 100, until: 200 }, 1_000)).to.deep.equal({
+ kinds: [1, 6, 7],
+ since: 100,
+ until: 200,
+ })
+ })
+
+ it('resolves a rolling window against the reconciliation time', () => {
+ expect(peeringScopeToFilter({ kinds: [1], windowSeconds: 300 }, 1_000)).to.deep.equal({
+ kinds: [1],
+ since: 700,
+ })
+ })
+
+ it('subscribes live from the recent overlap and skips scopes that already ended', () => {
+ const filters = peeringScopesToLiveFilters(
+ [
+ { kinds: [1], since: 100 },
+ { kinds: [7], until: 1_500 },
+ { kinds: [6], until: 900 },
+ { kinds: [30000], windowSeconds: 3600 },
+ ],
+ 1_000,
+ 60,
+ )
+
+ expect(filters).to.deep.equal([
+ { kinds: [1], since: 940 },
+ { kinds: [7], since: 940, until: 1_500 },
+ { kinds: [30000], since: 940 },
+ ])
+ })
+
+ it('splits wide kind ranges into filters that fit a peer limit', () => {
+ const filters = peeringScopeToFilters({ kinds: [0, [1, 7]], since: 100 }, 1_000, 4)
+
+ expect(filters.map((filter) => filter.kinds)).to.deep.equal([
+ [0, 1, 2, 3],
+ [4, 5, 6, 7],
+ ])
+ expect(filters.every((filter) => filter.since === 100)).to.equal(true)
+ })
+
+ it('never starts a short rolling window earlier than its own cutoff', () => {
+ const [filter] = peeringScopesToLiveFilters([{ kinds: [1], windowSeconds: 5 }], 1_000, 60)
+
+ expect(filter).to.deep.equal({ kinds: [1], since: 995 })
+ })
+
+ it('splits an oversized absolute range at its midpoint', () => {
+ expect(splitPeeringFilter({ kinds: [1], since: 100, until: 200 }, 1_000)).to.deep.equal([
+ { kinds: [1], since: 100, until: 150 },
+ { kinds: [1], since: 151, until: 200 },
+ ])
+ })
+
+ it('splits an unbounded filter between the beginning of time and now', () => {
+ expect(splitPeeringFilter({ kinds: [1] }, 1_000)).to.deep.equal([
+ { kinds: [1], since: 0, until: 500 },
+ { kinds: [1], since: 501, until: 1_000 },
+ ])
+ })
+
+ it('does not split a range that has no room left', () => {
+ expect(splitPeeringFilter({ kinds: [1], since: 500, until: 500 }, 1_000)).to.equal(undefined)
+ })
+})
diff --git a/test/unit/utils/peering-sync-state.spec.ts b/test/unit/utils/peering-sync-state.spec.ts
new file mode 100644
index 000000000..a0ece0710
--- /dev/null
+++ b/test/unit/utils/peering-sync-state.spec.ts
@@ -0,0 +1,66 @@
+import { expect } from 'chai'
+
+import { ICacheAdapter } from '../../../src/@types/adapters'
+import { PEERING_SYNC_STATE_INDEX_KEY, PeeringSyncStateStore } from '../../../src/utils/peering-sync-state'
+
+const createCache = (): ICacheAdapter & { values: Map; sets: Map } => {
+ const values = new Map()
+ const sets = new Map()
+
+ return {
+ values,
+ sets,
+ getKey: async (key) => values.get(key) ?? '',
+ hasKey: async (key) => values.has(key),
+ setKey: async (key, value) => {
+ values.set(key, value)
+ return true
+ },
+ setKeyIfNotExists: async (key, value) => {
+ if (values.has(key)) {
+ return false
+ }
+ values.set(key, value)
+ return true
+ },
+ addToSortedSet: async () => 0,
+ removeRangeByScoreFromSortedSet: async () => 0,
+ removeRangeByRankFromSortedSet: async () => 0,
+ getRangeFromSortedSet: async () => [],
+ setKeyExpiry: async () => {},
+ deleteKey: async () => 0,
+ getHKey: async () => '',
+ setHKey: async () => true,
+ addToSet: async (key, members) => {
+ const current = sets.get(key) ?? []
+ sets.set(key, [...new Set([...current, ...members])])
+ return members.length
+ },
+ getSetMembers: async (key) => sets.get(key) ?? [],
+ eval: async () => null,
+ }
+}
+
+describe('PeeringSyncStateStore', () => {
+ it('publishes and lists per-peer state', async () => {
+ const cache = createCache()
+ const store = new PeeringSyncStateStore(cache)
+
+ await store.publish({
+ pubkey: 'A'.repeat(64),
+ relayUrl: 'wss://peer.example.com',
+ status: 'live',
+ scopeCount: 1,
+ updatedAt: '2024-01-01T00:00:00.000Z',
+ lastReconciledAt: '2024-01-01T00:00:00.000Z',
+ lastError: 'failed to connect to wss://peer.example.com',
+ })
+
+ expect(cache.sets.get(PEERING_SYNC_STATE_INDEX_KEY)).to.deep.equal(['a'.repeat(64)])
+ const states = await store.list()
+
+ expect(states).to.have.length(1)
+ expect(states[0].pubkey).to.equal('a'.repeat(64))
+ expect(states[0].lastError).to.equal('failed to connect to [url]')
+ })
+})
diff --git a/test/unit/utils/safe-nip11-lookup.spec.ts b/test/unit/utils/safe-nip11-lookup.spec.ts
new file mode 100644
index 000000000..d3d49ab31
--- /dev/null
+++ b/test/unit/utils/safe-nip11-lookup.spec.ts
@@ -0,0 +1,37 @@
+import { expect } from 'chai'
+
+import { isBlockedNip11Address, safeNip11Lookup } from '../../../src/utils/relay-probe/nip11-probe'
+
+describe('safe NIP-11 lookup', () => {
+ it('blocks loopback, private, link-local, multicast, and mapped addresses', () => {
+ for (const address of [
+ '127.0.0.1',
+ '10.0.0.1',
+ '172.16.0.1',
+ '192.168.1.1',
+ '169.254.1.1',
+ '100.64.0.1',
+ '224.0.0.1',
+ '::1',
+ 'fd00::1',
+ 'fe80::1',
+ '::ffff:127.0.0.1',
+ ]) {
+ expect(isBlockedNip11Address(address), address).to.equal(true)
+ }
+ })
+
+ it('allows public addresses', () => {
+ expect(isBlockedNip11Address('8.8.8.8')).to.equal(false)
+ expect(isBlockedNip11Address('2606:4700:4700::1111')).to.equal(false)
+ })
+
+ it('rejects a literal private address before connecting', async () => {
+ const error = await safeNip11Lookup('127.0.0.1', {}).then(
+ () => undefined,
+ (err: Error) => err,
+ )
+
+ expect(error?.message).to.include('resolving to a private address')
+ })
+})