From c98a7f545241e10a4e06ec396a0cfd4dad430177 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Sat, 3 Oct 2026 22:07:37 +0530 Subject: [PATCH 1/4] feat(nip66): advanced probes, enriched 30166, and mirror targets Add WebSocket read/write RTT probes with NIP-42 detection, probe mirroring.static peers, and publish richer NIP-66 discovery and monitor events from the self-hosted monitor vantage. --- .changeset/nip66-v2-advanced-probes.md | 9 + CONFIGURATION.md | 3 +- resources/admin/assets/dashboard.js | 18 +- src/@types/settings.ts | 4 + src/app/relay-monitor-worker.ts | 3 + src/services/nip66-event-publisher.ts | 2 +- src/utils/nip66-events.ts | 41 ++- src/utils/nip66-nip11-tags.ts | 62 ++++ src/utils/relay-probe-targets.ts | 39 ++- src/utils/relay-probe/nip11-probe.ts | 41 +++ src/utils/relay-probe/run-probe.ts | 15 +- src/utils/relay-probe/types.ts | 16 + src/utils/relay-probe/ws-protocol-probe.ts | 331 ++++++++++++++++++++ test/unit/utils/nip66-events.spec.ts | 36 ++- test/unit/utils/relay-probe-run.spec.ts | 9 +- test/unit/utils/relay-probe-targets.spec.ts | 29 ++ 16 files changed, 635 insertions(+), 23 deletions(-) create mode 100644 .changeset/nip66-v2-advanced-probes.md create mode 100644 src/utils/nip66-nip11-tags.ts create mode 100644 src/utils/relay-probe/ws-protocol-probe.ts diff --git a/.changeset/nip66-v2-advanced-probes.md b/.changeset/nip66-v2-advanced-probes.md new file mode 100644 index 00000000..3bba9bae --- /dev/null +++ b/.changeset/nip66-v2-advanced-probes.md @@ -0,0 +1,9 @@ +--- +"nostream": minor +--- + +feat(nip66): add read/write RTT probes, NIP-42 detection, mirror targets, and enriched kind 30166 tags + +Extends the relay monitor with WebSocket read/write probes, optional NIP-42 auth detection, probing of `mirroring.static[]` peers, and richer NIP-66 discovery and monitor announcement events. + +Closes #794 diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 81881c79..c1792182 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -225,7 +225,8 @@ The settings below are listed in alphabetical order by name. Please keep this ta | nip66.dnsCacheTtlSeconds | DNS cache TTL in seconds for repeated probe lookups of the same hostname. Defaults to 300. | | nip66.enabled | Enable NIP-66 relay monitoring. When true, starts a `relay-monitor` cluster worker that probes targets on an interval and stores the latest snapshot in Redis. Defaults to false. | | nip66.probeIntervalSeconds | Seconds between scheduled relay probe runs. Defaults to 3600. | -| nip66.targets | Public WebSocket URLs to probe (for example `wss://relay.example.com`). When empty, defaults to `info.relay_url`. | +| nip66.targets | Public WebSocket URLs to probe (for example `wss://relay.example.com`). When empty, defaults to `info.relay_url`. Mirror addresses from `mirroring.static[]` are always probed in addition. | +| nip66.geohash | Optional NIP-52 geohash describing this monitor's network vantage. Published on kind `10166` monitor announcements when set. | | nip66.timeouts.dnsMs | DNS probe timeout in milliseconds. Defaults to 10000. | | nip66.timeouts.nip11Ms | NIP-11 fetch timeout in milliseconds. Defaults to 10000. | | nip66.timeouts.tlsMs | TLS probe timeout in milliseconds. Defaults to 10000. | diff --git a/resources/admin/assets/dashboard.js b/resources/admin/assets/dashboard.js index 039793fa..18e785d0 100644 --- a/resources/admin/assets/dashboard.js +++ b/resources/admin/assets/dashboard.js @@ -517,7 +517,23 @@ }, { tlsDaysUntilExpiry }, ) - const wsRtt = formatProbeCheckDetail(result.wsRtt, (data) => `${data.rttOpenMs} ms`) + const wsRtt = formatProbeCheckDetail(result.wsRtt, (data) => { + const parts = [`open ${data.rttOpenMs} ms`] + + if (typeof data.rttReadMs === 'number') { + parts.push(`read ${data.rttReadMs} ms`) + } + + if (typeof data.rttWriteMs === 'number') { + parts.push(`write ${data.rttWriteMs} ms`) + } + + if (data.nip42AuthRequired === true) { + parts.push('NIP-42 auth required') + } + + return parts.join(' · ') + }) const nip11 = formatProbeCheckDetail(result.nip11, (data) => { const name = data?.name ? ` ${data.name}` : '' const supportedNips = Array.isArray(data?.supportedNips) ? data.supportedNips : null diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 1a5db8c1..874c696c 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -347,6 +347,10 @@ export interface Nip66Settings { * Defaults to 300. */ dnsCacheTtlSeconds: number + /** + * Optional NIP-52 geohash describing this monitor's network vantage for kind 10166 events. + */ + geohash?: string } export interface Nip05Settings { diff --git a/src/app/relay-monitor-worker.ts b/src/app/relay-monitor-worker.ts index 48097f69..1f5f848a 100644 --- a/src/app/relay-monitor-worker.ts +++ b/src/app/relay-monitor-worker.ts @@ -7,6 +7,7 @@ import { shutdownMetricsTelemetry } from '../telemetry/metrics' import { filterValidProbeTargets, resolveProbeTargets } from '../utils/relay-probe-targets' import { deriveRelayProbeRunStatus, serializeProbeResults } from '../utils/relay-probe-snapshot' import { getEffectiveProbeIntervalSeconds, getProbeIntervalMs } from '../utils/nip66-schedule' +import { getMonitorPrivateKey } from '../utils/monitor-identity' import { runProbe } from '../utils/relay-probe' import { ProbeOptions, ProbeResult } from '../utils/relay-probe/types' @@ -16,10 +17,12 @@ export type RunProbeFn = (relayUrl: string, options?: ProbeOptions) => Promise

{ const nip66 = settings.nip66 + const monitorPrivateKey = getMonitorPrivateKey() return { timeouts: nip66?.timeouts, dnsCacheTtlSeconds: nip66?.dnsCacheTtlSeconds, + ...(monitorPrivateKey ? { monitorPrivateKey } : {}), } } diff --git a/src/services/nip66-event-publisher.ts b/src/services/nip66-event-publisher.ts index 337e04a7..25e3ebbe 100644 --- a/src/services/nip66-event-publisher.ts +++ b/src/services/nip66-event-publisher.ts @@ -72,7 +72,7 @@ export class Nip66EventPublisher implements INip66EventPublisher { return } - await this.persistSignedEvent(buildMonitorProfileEvent(monitorPubkey, createdAt), privkey) + await this.persistSignedEvent(buildMonitorProfileEvent(monitorPubkey, createdAt, settings), privkey) await this.persistSignedEvent(buildMonitorRelayListEvent(relayUrl, monitorPubkey, createdAt), privkey) await this.cache.setKey(NIP66_MONITOR_BOOTSTRAPPED_KEY, monitorPubkey, NIP66_MONITOR_BOOTSTRAP_TTL_SECONDS) diff --git a/src/utils/nip66-events.ts b/src/utils/nip66-events.ts index da9ca75e..ddf65b66 100644 --- a/src/utils/nip66-events.ts +++ b/src/utils/nip66-events.ts @@ -3,6 +3,8 @@ import { Tag } from '../@types/base' import { StoredProbeResult } from '../@types/relay-probe-snapshot' import { Settings } from '../@types/settings' import { EventKinds, EventTags } from '../constants/base' +import { geohashSchema } from '../schemas/base-schema' +import { appendNip11DiscoveryTags, appendWsProbeDiscoveryTags } from './nip66-nip11-tags' import { getEffectiveProbeIntervalSeconds } from './nip66-schedule' const appendDnsProbeTags = (tags: Tag[], dns: StoredProbeResult['dns']): void => { @@ -74,18 +76,21 @@ export const buildRelayDiscoveryEvent = ( ['n', result.target.networkType], ] - if (result.wsRtt.status === 'ok' && typeof result.wsRtt.data?.rttOpenMs === 'number') { - tags.push(['rtt-open', String(result.wsRtt.data.rttOpenMs)]) - } - + appendWsProbeDiscoveryTags(tags, result) + appendNip11DiscoveryTags(tags, result) appendDnsProbeTags(tags, result.dns) appendTlsProbeTags(tags, result.tls) + const nip11Content = + result.nip11.status === 'ok' && typeof result.nip11.data?.rawDocument === 'string' + ? result.nip11.data.rawDocument + : '' + return { kind: EventKinds.RELAY_DISCOVERY, pubkey: monitorPubkey, created_at: createdAt, - content: '', + content: nip11Content, tags, } } @@ -100,14 +105,25 @@ export const buildMonitorAnnouncementEvent = ( const tags: Tag[] = [ ['frequency', String(getEffectiveProbeIntervalSeconds(settings))], - ['c', 'ws'], + ['c', 'open'], + ['c', 'read'], + ['c', 'write'], + ['c', 'auth'], ['c', 'nip11'], ['c', 'ssl'], ['c', 'dns'], ] + const geohash = settings.nip66?.geohash?.trim() + + if (geohash && geohashSchema.safeParse(geohash).success) { + tags.push(['g', geohash]) + } + if (timeouts) { tags.push(['timeout', 'open', String(timeouts.wsRttMs)]) + tags.push(['timeout', 'read', String(timeouts.wsRttMs)]) + tags.push(['timeout', 'write', String(timeouts.wsRttMs)]) tags.push(['timeout', 'nip11', String(timeouts.nip11Ms)]) tags.push(['timeout', 'dns', String(timeouts.dnsMs)]) tags.push(['timeout', 'ssl', String(timeouts.tlsMs)]) @@ -122,14 +138,21 @@ export const buildMonitorAnnouncementEvent = ( } } -export const buildMonitorProfileEvent = (monitorPubkey: string, createdAt: number): UnidentifiedEvent => { +export const buildMonitorProfileEvent = ( + monitorPubkey: string, + createdAt: number, + settings?: Settings, +): UnidentifiedEvent => { + const relayName = settings?.info?.name?.trim() + return { kind: EventKinds.SET_METADATA, pubkey: monitorPubkey, created_at: createdAt, content: JSON.stringify({ - name: 'Nostream Relay Monitor', - about: 'Automated NIP-66 relay health monitor for this Nostream instance.', + name: relayName ? `${relayName} (self-hosted monitor)` : 'Nostream self-hosted relay monitor', + about: + 'Self-hosted NIP-66 monitor for this Nostream relay. Measurements reflect this relay’s network vantage only and are not authoritative for the wider network.', }), tags: [], } diff --git a/src/utils/nip66-nip11-tags.ts b/src/utils/nip66-nip11-tags.ts new file mode 100644 index 00000000..67dd475b --- /dev/null +++ b/src/utils/nip66-nip11-tags.ts @@ -0,0 +1,62 @@ +import { Tag } from '../@types/base' +import { StoredProbeResult } from '../@types/relay-probe-snapshot' +import { Nip11Limitation } from './relay-probe/types' + +const requirementTag = (name: string, required: boolean | undefined): Tag | undefined => { + if (required === undefined) { + return undefined + } + + return ['R', required ? name : `!${name}`] +} + +export const appendNip11DiscoveryTags = (tags: Tag[], result: StoredProbeResult): void => { + const nip11 = result.nip11.status === 'ok' ? result.nip11.data : undefined + + if (!nip11) { + return + } + + for (const nip of nip11.supportedNips ?? []) { + tags.push(['N', String(nip)]) + } + + const limitation: Nip11Limitation | undefined = nip11.limitation + + const requirementTags = [ + requirementTag('auth', limitation?.authRequired), + requirementTag('writes', limitation?.restrictedWrites), + requirementTag('payment', limitation?.paymentRequired), + requirementTag('pow', (limitation?.minPowDifficulty ?? 0) > 0), + ].filter((tag): tag is Tag => tag !== undefined) + + tags.push(...requirementTags) + + for (const kind of nip11.acceptedKinds ?? []) { + tags.push(['k', String(kind)]) + } +} + +export const appendWsProbeDiscoveryTags = (tags: Tag[], result: StoredProbeResult): void => { + const ws = result.wsRtt.status === 'ok' ? result.wsRtt.data : undefined + + if (!ws) { + return + } + + if (typeof ws.rttOpenMs === 'number') { + tags.push(['rtt-open', String(ws.rttOpenMs)]) + } + + if (typeof ws.rttReadMs === 'number') { + tags.push(['rtt-read', String(ws.rttReadMs)]) + } + + if (typeof ws.rttWriteMs === 'number') { + tags.push(['rtt-write', String(ws.rttWriteMs)]) + } + + if (ws.nip42AuthRequired === true) { + tags.push(['R', 'auth']) + } +} diff --git a/src/utils/relay-probe-targets.ts b/src/utils/relay-probe-targets.ts index 02bf02b3..e4d3e3bf 100644 --- a/src/utils/relay-probe-targets.ts +++ b/src/utils/relay-probe-targets.ts @@ -1,15 +1,48 @@ import { Settings } from '../@types/settings' import { parseProbeTarget } from './relay-probe' +import { normalizeRelayUrlForDTag } from './nip66-events' + +const addUniqueTarget = (targets: string[], seen: Set, candidate: string | undefined): void => { + const trimmed = candidate?.trim() + + if (!trimmed) { + return + } + + let dedupeKey: string + + try { + dedupeKey = normalizeRelayUrlForDTag(trimmed) + } catch { + dedupeKey = trimmed.toLowerCase() + } + + if (seen.has(dedupeKey)) { + return + } + + seen.add(dedupeKey) + targets.push(trimmed) +} export const resolveProbeTargets = (settings: Settings): string[] => { + const targets: string[] = [] + const seen = new Set() const configured = settings.nip66?.targets?.map((target) => target.trim()).filter(Boolean) ?? [] if (configured.length > 0) { - return configured + for (const target of configured) { + addUniqueTarget(targets, seen, target) + } + } else { + addUniqueTarget(targets, seen, settings.info?.relay_url) + } + + for (const mirror of settings.mirroring?.static ?? []) { + addUniqueTarget(targets, seen, mirror.address) } - const relayUrl = settings.info?.relay_url?.trim() - return relayUrl ? [relayUrl] : [] + return targets } export const filterValidProbeTargets = (targets: string[]): { valid: string[]; invalid: string[] } => { diff --git a/src/utils/relay-probe/nip11-probe.ts b/src/utils/relay-probe/nip11-probe.ts index 59f7f5ec..2784dc18 100644 --- a/src/utils/relay-probe/nip11-probe.ts +++ b/src/utils/relay-probe/nip11-probe.ts @@ -7,11 +7,33 @@ import { Nip11Result } from './types' const MAX_RESPONSE_BYTES = 256 * 1024 const MAX_REDIRECTS = 1 +const nip11LimitationSchema = z + .object({ + auth_required: z.boolean().optional(), + restricted_writes: z.boolean().optional(), + payment_required: z.boolean().optional(), + min_pow_difficulty: z.number().optional(), + }) + .passthrough() + const nip11DocumentSchema = z .object({ name: z.string().optional(), pubkey: pubkeySchema.optional(), supported_nips: z.array(z.number().int().positive()).optional(), + limitation: nip11LimitationSchema.optional(), + fees: z + .object({ + publication: z + .array( + z.object({ + kinds: z.array(z.number().int()).optional(), + }), + ) + .optional(), + }) + .optional(), + accepted_kinds: z.array(z.number().int()).optional(), }) .passthrough() @@ -93,11 +115,30 @@ export const createNodeNip11Fetcher = (): Nip11Fetcher => ({ throw new Error(`invalid NIP-11 document: ${reason}`) } + const limitation = parsed.data.limitation + const acceptedKinds = new Set(parsed.data.accepted_kinds ?? []) + + for (const publicationFee of parsed.data.fees?.publication ?? []) { + for (const kind of publicationFee.kinds ?? []) { + acceptedKinds.add(kind) + } + } + return { statusCode: response.status, name: parsed.data.name, pubkey: parsed.data.pubkey, supportedNips: parsed.data.supported_nips, + limitation: limitation + ? { + authRequired: limitation.auth_required, + restrictedWrites: limitation.restricted_writes, + paymentRequired: limitation.payment_required, + minPowDifficulty: limitation.min_pow_difficulty, + } + : undefined, + acceptedKinds: acceptedKinds.size > 0 ? [...acceptedKinds].sort((a, b) => a - b) : undefined, + rawDocument: JSON.stringify(parsed.data), } } catch (error: unknown) { const axiosError = error as AxiosError diff --git a/src/utils/relay-probe/run-probe.ts b/src/utils/relay-probe/run-probe.ts index ee5f1f94..55fefd79 100644 --- a/src/utils/relay-probe/run-probe.ts +++ b/src/utils/relay-probe/run-probe.ts @@ -13,19 +13,23 @@ import { ProbeTarget, ProbeTimeouts, } from './types' -import { createNodeWebSocketConnector, probeWebSocketRtt, WebSocketConnector } from './ws-rtt-probe' +import { + createNodeWebSocketProtocolConnector, + probeWebSocketProtocol, + WebSocketProtocolConnector, +} from './ws-protocol-probe' export interface ProbeClients { dns: DnsResolver tls: TlsConnector - ws: WebSocketConnector + ws: WebSocketProtocolConnector nip11: Nip11Fetcher } export const createDefaultProbeClients = (): ProbeClients => ({ dns: createNodeDnsResolver(), tls: createNodeTlsConnector(), - ws: createNodeWebSocketConnector(), + ws: createNodeWebSocketProtocolConnector(), nip11: createNodeNip11Fetcher(), }) @@ -120,6 +124,7 @@ export const runProbe = async ( const timeouts = mergeTimeouts(options.timeouts) const dnsCacheTtlSeconds = options.dnsCacheTtlSeconds ?? DEFAULT_DNS_CACHE_TTL_SECONDS const skipDnsCache = options.skipDnsCache ?? false + const monitorPrivateKey = options.monitorPrivateKey const dnsStartedAt = Date.now() const dnsPromise = runDnsProbe(clients, target, { dnsCacheTtlSeconds, skipDnsCache }) @@ -136,7 +141,9 @@ export const runProbe = async ( const [dns, tls, wsRtt, nip11] = await Promise.all([ Promise.race([dnsPromise, dnsTimeout]), runTimedProbe(() => probeTls(clients.tls, target, timeouts.tlsMs)), - runTimedProbe(() => probeWebSocketRtt(clients.ws, target, timeouts.wsRttMs)), + runTimedProbe(() => + probeWebSocketProtocol(clients.ws, target, timeouts.wsRttMs, { monitorPrivateKey }), + ), runTimedProbe(() => probeNip11(clients.nip11, target.nip11Url, timeouts.nip11Ms)), ]) diff --git a/src/utils/relay-probe/types.ts b/src/utils/relay-probe/types.ts index 3ef22328..cc8614cd 100644 --- a/src/utils/relay-probe/types.ts +++ b/src/utils/relay-probe/types.ts @@ -44,14 +44,28 @@ export interface TlsResult { export interface WsRttResult { rttOpenMs: number + rttReadMs?: number + rttWriteMs?: number + nip42AuthRequired?: boolean + nip42ChallengeObserved?: boolean address: string } +export interface Nip11Limitation { + authRequired?: boolean + restrictedWrites?: boolean + paymentRequired?: boolean + minPowDifficulty?: number +} + export interface Nip11Result { statusCode: number name?: string pubkey?: string supportedNips?: number[] + limitation?: Nip11Limitation + acceptedKinds?: number[] + rawDocument?: string } export interface ProbeResult { @@ -83,4 +97,6 @@ export interface ProbeOptions { timeouts?: Partial dnsCacheTtlSeconds?: number skipDnsCache?: boolean + /** When set, write probes and NIP-42 authentication use this monitor identity. */ + monitorPrivateKey?: string } diff --git a/src/utils/relay-probe/ws-protocol-probe.ts b/src/utils/relay-probe/ws-protocol-probe.ts new file mode 100644 index 00000000..07e0a0eb --- /dev/null +++ b/src/utils/relay-probe/ws-protocol-probe.ts @@ -0,0 +1,331 @@ +import { EventKinds, EventTags } from '../../constants/base' +import { Event, UnidentifiedEvent } from '../../@types/event' +import { getPublicKey, identifyEvent, signEvent } from '../event' +import { ProbeTarget, WsRttResult } from './types' + +const PROBE_SUBSCRIPTION_ID = 'nip66-probe-read' +const PROBE_COUNT_FILTER = { kinds: [1] } + +export interface WebSocketProtocolProbeOptions { + monitorPrivateKey?: string +} + +export interface WebSocketProtocolConnector { + measureProtocol( + target: ProbeTarget, + timeoutMs: number, + options?: WebSocketProtocolProbeOptions, + ): Promise +} + +type NostrWireMessage = unknown[] + +const isAuthRequiredMessage = (message: string | undefined): boolean => + typeof message === 'string' && message.toLowerCase().includes('auth-required') + +const parseWireMessage = (raw: unknown): NostrWireMessage | undefined => { + if (typeof raw !== 'string') { + return undefined + } + + try { + const parsed = JSON.parse(raw) as unknown + return Array.isArray(parsed) ? parsed : undefined + } catch { + return undefined + } +} + +const buildAuthEvent = async ( + privateKey: string, + challenge: string, + relayUrl: string, +): Promise => { + const pubkey = getPublicKey(privateKey) + const unsigned: UnidentifiedEvent = { + kind: EventKinds.AUTH, + pubkey, + created_at: Math.floor(Date.now() / 1000), + content: '', + tags: [ + [EventTags.AuthRelay, relayUrl], + [EventTags.Challenge, challenge], + ], + } + + return signEvent(privateKey)(await identifyEvent(unsigned)) +} + +const buildWriteProbeEvent = async (privateKey: string): Promise => { + const pubkey = getPublicKey(privateKey) + const unsigned: UnidentifiedEvent = { + kind: EventKinds.EPHEMERAL_FIRST, + pubkey, + created_at: Math.floor(Date.now() / 1000), + content: 'nip66 write probe', + tags: [[EventTags.Deduplication, 'nip66-write-probe']], + } + + return signEvent(privateKey)(await identifyEvent(unsigned)) +} + +export const createNodeWebSocketProtocolConnector = (): WebSocketProtocolConnector => { + const { WebSocket } = require('ws') as typeof import('ws') + + return { + measureProtocol: (target, timeoutMs, options) => + new Promise((resolve, reject) => { + const startedAt = Date.now() + const socket = new WebSocket(target.wsUrl, { handshakeTimeout: timeoutMs }) + let settled = false + let openAt: number | undefined + let authChallenge: string | undefined + let nip42AuthRequired = false + + const finish = (error?: Error, result?: WsRttResult) => { + if (settled) { + return + } + + settled = true + socket.removeAllListeners() + + if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) { + socket.terminate() + } + + if (error) { + reject(error) + return + } + + resolve(result as WsRttResult) + } + + const observeAuthChallenge = (raw: unknown): void => { + const message = parseWireMessage(raw) + + if (message?.[0] === 'AUTH' && typeof message[1] === 'string') { + authChallenge = message[1] + nip42AuthRequired = true + } + } + + const waitForMessage = ( + predicate: (message: NostrWireMessage) => boolean, + remainingMs: number, + ): Promise => + new Promise((messageResolve, messageReject) => { + if (remainingMs <= 0) { + messageReject(new Error('WebSocket probe timed out')) + return + } + + const deadline = Date.now() + remainingMs + + const onMessage = (raw: unknown) => { + observeAuthChallenge(raw) + const message = parseWireMessage(raw) + + if (!message) { + return + } + + if (predicate(message)) { + cleanup() + messageResolve(message) + } else if (Date.now() >= deadline) { + cleanup() + messageReject(new Error('WebSocket probe timed out waiting for response')) + } + } + + const onError = (error: Error) => { + cleanup() + messageReject(error) + } + + const onClose = () => { + cleanup() + messageReject(new Error('WebSocket closed before expected response')) + } + + const cleanup = () => { + socket.off('message', onMessage) + socket.off('error', onError) + socket.off('close', onClose) + } + + socket.on('message', onMessage) + socket.on('error', onError) + socket.on('close', onClose) + }) + + const waitForOptionalAuthChallenge = (remainingMs: number): Promise => + new Promise((resolve) => { + const waitMs = Math.min(250, remainingMs) + + if (waitMs <= 0) { + resolve() + return + } + + const onMessage = (raw: unknown) => { + observeAuthChallenge(raw) + + if (authChallenge) { + cleanup() + resolve() + } + } + + const timer = setTimeout(() => { + cleanup() + resolve() + }, waitMs) + + const cleanup = () => { + clearTimeout(timer) + socket.off('message', onMessage) + } + + socket.on('message', onMessage) + }) + + const remainingTimeoutMs = (): number => Math.max(0, timeoutMs - (Date.now() - startedAt)) + + const authenticateIfNeeded = async (): Promise => { + const privateKey = options?.monitorPrivateKey?.trim() + + if (!authChallenge || !privateKey) { + return + } + + const authEvent = await buildAuthEvent(privateKey, authChallenge, target.relayUrl) + socket.send(JSON.stringify(['AUTH', authEvent])) + + const authResponse = await waitForMessage( + (message) => message[0] === 'OK' && message[1] === authEvent.id, + remainingTimeoutMs(), + ) + + if (authResponse[2] !== true) { + throw new Error(`NIP-42 authentication failed: ${String(authResponse[3] ?? '')}`) + } + } + + const measureReadRtt = async (): Promise => { + const runCount = async (): Promise => { + const readStartedAt = Date.now() + socket.send(JSON.stringify(['COUNT', PROBE_SUBSCRIPTION_ID, PROBE_COUNT_FILTER])) + + const response = await waitForMessage( + (message) => + (message[0] === 'COUNT' && message[1] === PROBE_SUBSCRIPTION_ID) || + (message[0] === 'CLOSED' && message[1] === PROBE_SUBSCRIPTION_ID), + remainingTimeoutMs(), + ) + + if (response[0] === 'CLOSED' && isAuthRequiredMessage(String(response[2] ?? ''))) { + nip42AuthRequired = true + return undefined + } + + if (response[0] !== 'COUNT') { + throw new Error(`Unexpected read probe response: ${JSON.stringify(response)}`) + } + + return Date.now() - readStartedAt + } + + let rtt = await runCount() + + if (rtt === undefined && nip42AuthRequired && options?.monitorPrivateKey?.trim()) { + await authenticateIfNeeded() + rtt = await runCount() + } + + return rtt + } + + const measureWriteRtt = async (): Promise => { + const privateKey = options?.monitorPrivateKey?.trim() + + if (!privateKey) { + return undefined + } + + const runWrite = async (): Promise => { + const writeEvent = await buildWriteProbeEvent(privateKey) + const writeStartedAt = Date.now() + socket.send(JSON.stringify(['EVENT', writeEvent])) + + const response = await waitForMessage( + (message) => message[0] === 'OK' && message[1] === writeEvent.id, + remainingTimeoutMs(), + ) + + if (response[2] !== true && isAuthRequiredMessage(String(response[3] ?? ''))) { + nip42AuthRequired = true + return undefined + } + + if (response[2] !== true) { + throw new Error(`Write probe rejected: ${String(response[3] ?? '')}`) + } + + return Date.now() - writeStartedAt + } + + let rtt = await runWrite() + + if (rtt === undefined && nip42AuthRequired) { + await authenticateIfNeeded() + rtt = await runWrite() + } + + return rtt + } + + socket.once('open', () => { + openAt = Date.now() + + void (async () => { + try { + await waitForOptionalAuthChallenge(remainingTimeoutMs()) + + const rttReadMs = await measureReadRtt() + const rttWriteMs = await measureWriteRtt() + + finish(undefined, { + rttOpenMs: openAt! - startedAt, + rttReadMs, + rttWriteMs, + nip42AuthRequired: nip42AuthRequired || undefined, + nip42ChallengeObserved: authChallenge ? true : undefined, + address: target.wsUrl, + }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + finish(new Error(message)) + } + })() + }) + + socket.once('error', (error) => { + finish(error instanceof Error ? error : new Error(String(error))) + }) + + setTimeout(() => { + finish(new Error(`WebSocket probe timed out after ${timeoutMs}ms`)) + }, timeoutMs).unref() + }), + } +} + +export const probeWebSocketProtocol = async ( + connector: WebSocketProtocolConnector, + target: ProbeTarget, + timeoutMs: number, + options?: WebSocketProtocolProbeOptions, +): Promise => connector.measureProtocol(target, timeoutMs, options) diff --git a/test/unit/utils/nip66-events.spec.ts b/test/unit/utils/nip66-events.spec.ts index 67b0de0d..cc87731d 100644 --- a/test/unit/utils/nip66-events.spec.ts +++ b/test/unit/utils/nip66-events.spec.ts @@ -29,8 +29,22 @@ const storedProbeResult = (relayUrl = 'wss://Relay.Example.com:443/'): StoredPro checkedAt: '2026-01-01T00:00:00.000Z', dns: { status: 'ok', durationMs: 1 }, tls: { status: 'ok', durationMs: 1, data: { valid: true, issuer: 'Test CA' } }, - wsRtt: { status: 'ok', durationMs: 12, data: { rttOpenMs: 234, address: '127.0.0.1:443' } }, - nip11: { status: 'ok', durationMs: 1 }, + wsRtt: { + status: 'ok', + durationMs: 12, + data: { rttOpenMs: 234, rttReadMs: 300, rttWriteMs: 320, address: '127.0.0.1:443' }, + }, + nip11: { + status: 'ok', + durationMs: 1, + data: { + statusCode: 200, + supportedNips: [1, 11], + limitation: { authRequired: false, restrictedWrites: false, paymentRequired: false, minPowDifficulty: 0 }, + acceptedKinds: [1, 7], + rawDocument: '{"name":"relay"}', + }, + }, }) as StoredProbeResult describe('nip66-events', () => { @@ -47,6 +61,16 @@ describe('nip66-events', () => { expect(event.tags).to.deep.include(['d', 'wss://relay.example.com/']) expect(event.tags).to.deep.include(['n', 'clearnet']) expect(event.tags).to.deep.include(['rtt-open', '234']) + expect(event.tags).to.deep.include(['rtt-read', '300']) + expect(event.tags).to.deep.include(['rtt-write', '320']) + expect(event.tags).to.deep.include(['N', '1']) + expect(event.tags).to.deep.include(['N', '11']) + expect(event.tags).to.deep.include(['R', '!auth']) + expect(event.tags).to.deep.include(['R', '!writes']) + expect(event.tags).to.deep.include(['R', '!payment']) + expect(event.tags).to.deep.include(['R', '!pow']) + expect(event.tags).to.deep.include(['k', '1']) + expect(event.content).to.equal('{"name":"relay"}') expect(event.tags).to.deep.include(['dns', 'resolved']) expect(event.tags).to.deep.include(['ssl', 'valid']) }) @@ -73,7 +97,12 @@ describe('nip66-events', () => { expect(event.kind).to.equal(EventKinds.RELAY_MONITOR_ANNOUNCEMENT) expect(event.tags).to.deep.include(['frequency', String(MIN_PROBE_INTERVAL_SECONDS)]) expect(event.tags).to.deep.include(['timeout', 'open', '3000']) + expect(event.tags).to.deep.include(['timeout', 'read', '3000']) + expect(event.tags).to.deep.include(['timeout', 'write', '3000']) expect(event.tags).to.deep.include(['timeout', 'nip11', '4000']) + expect(event.tags).to.deep.include(['c', 'read']) + expect(event.tags).to.deep.include(['c', 'write']) + expect(event.tags).to.deep.include(['c', 'auth']) expect(event.tags).to.deep.include(['c', 'dns']) }) @@ -82,7 +111,8 @@ describe('nip66-events', () => { const relayList = buildMonitorRelayListEvent('wss://relay.example.com', monitorPubkey, 1) expect(profile.kind).to.equal(EventKinds.SET_METADATA) - expect(JSON.parse(profile.content).name).to.equal('Nostream Relay Monitor') + expect(JSON.parse(profile.content).name).to.equal('Nostream self-hosted relay monitor') + expect(JSON.parse(profile.content).about).to.include('not authoritative') expect(relayList.kind).to.equal(EventKinds.RELAY_LIST) expect(relayList.tags).to.deep.equal([ diff --git a/test/unit/utils/relay-probe-run.spec.ts b/test/unit/utils/relay-probe-run.spec.ts index 27379535..77f17899 100644 --- a/test/unit/utils/relay-probe-run.spec.ts +++ b/test/unit/utils/relay-probe-run.spec.ts @@ -21,7 +21,14 @@ const makeClients = (overrides: Record = {}) => ({ daysUntilExpiry: 365, }), }, - ws: { measureOpenRtt: async () => 42 }, + ws: { + measureProtocol: async () => ({ + rttOpenMs: 42, + rttReadMs: 55, + rttWriteMs: 67, + address: 'wss://relay.example.com', + }), + }, nip11: { fetch: async () => ({ statusCode: 200, diff --git a/test/unit/utils/relay-probe-targets.spec.ts b/test/unit/utils/relay-probe-targets.spec.ts index f4308b6f..c78b7a41 100644 --- a/test/unit/utils/relay-probe-targets.spec.ts +++ b/test/unit/utils/relay-probe-targets.spec.ts @@ -38,6 +38,35 @@ describe('relay-probe-targets', () => { expect(resolveProbeTargets(settings)).to.deep.equal(['wss://one.example', 'wss://two.example']) }) + it('includes mirroring.static addresses in addition to configured targets', () => { + const settings = { + ...baseSettings, + nip66: { + ...baseSettings.nip66!, + targets: ['wss://one.example'], + }, + mirroring: { + static: [{ address: 'wss://mirror.example' }, { address: 'wss://one.example' }], + }, + } as Settings + + expect(resolveProbeTargets(settings)).to.deep.equal(['wss://one.example', 'wss://mirror.example']) + }) + + it('includes mirror targets when falling back to info.relay_url', () => { + const settings = { + ...baseSettings, + mirroring: { + static: [{ address: 'wss://mirror.example' }], + }, + } as Settings + + expect(resolveProbeTargets(settings)).to.deep.equal([ + 'wss://relay.example.com', + 'wss://mirror.example', + ]) + }) + it('filters invalid probe targets', () => { const filtered = filterValidProbeTargets(['wss://valid.example', 'not-a-url']) From a1062a502f9d6cce97c104be97470d9cbe8a0369 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Sat, 3 Oct 2026 22:18:57 +0530 Subject: [PATCH 2/4] fix(nip66): keep ws probe ok when read/write probes are best-effort --- src/utils/relay-probe/ws-protocol-probe.ts | 92 ++++++++++++++++------ 1 file changed, 67 insertions(+), 25 deletions(-) diff --git a/src/utils/relay-probe/ws-protocol-probe.ts b/src/utils/relay-probe/ws-protocol-probe.ts index 07e0a0eb..2c3bc93e 100644 --- a/src/utils/relay-probe/ws-protocol-probe.ts +++ b/src/utils/relay-probe/ws-protocol-probe.ts @@ -193,6 +193,7 @@ export const createNodeWebSocketProtocolConnector = (): WebSocketProtocolConnect }) const remainingTimeoutMs = (): number => Math.max(0, timeoutMs - (Date.now() - startedAt)) + const phaseTimeoutMs = (): number => Math.max(500, Math.min(4_000, remainingTimeoutMs())) const authenticateIfNeeded = async (): Promise => { const privateKey = options?.monitorPrivateKey?.trim() @@ -206,7 +207,7 @@ export const createNodeWebSocketProtocolConnector = (): WebSocketProtocolConnect const authResponse = await waitForMessage( (message) => message[0] === 'OK' && message[1] === authEvent.id, - remainingTimeoutMs(), + phaseTimeoutMs(), ) if (authResponse[2] !== true) { @@ -223,7 +224,7 @@ export const createNodeWebSocketProtocolConnector = (): WebSocketProtocolConnect (message) => (message[0] === 'COUNT' && message[1] === PROBE_SUBSCRIPTION_ID) || (message[0] === 'CLOSED' && message[1] === PROBE_SUBSCRIPTION_ID), - remainingTimeoutMs(), + phaseTimeoutMs(), ) if (response[0] === 'CLOSED' && isAuthRequiredMessage(String(response[2] ?? ''))) { @@ -232,17 +233,27 @@ export const createNodeWebSocketProtocolConnector = (): WebSocketProtocolConnect } if (response[0] !== 'COUNT') { - throw new Error(`Unexpected read probe response: ${JSON.stringify(response)}`) + return undefined } return Date.now() - readStartedAt } - let rtt = await runCount() + let rtt: number | undefined - if (rtt === undefined && nip42AuthRequired && options?.monitorPrivateKey?.trim()) { - await authenticateIfNeeded() + try { rtt = await runCount() + } catch { + return undefined + } + + if (rtt === undefined && nip42AuthRequired && options?.monitorPrivateKey?.trim()) { + try { + await authenticateIfNeeded() + rtt = await runCount() + } catch { + return undefined + } } return rtt @@ -262,7 +273,7 @@ export const createNodeWebSocketProtocolConnector = (): WebSocketProtocolConnect const response = await waitForMessage( (message) => message[0] === 'OK' && message[1] === writeEvent.id, - remainingTimeoutMs(), + phaseTimeoutMs(), ) if (response[2] !== true && isAuthRequiredMessage(String(response[3] ?? ''))) { @@ -271,17 +282,27 @@ export const createNodeWebSocketProtocolConnector = (): WebSocketProtocolConnect } if (response[2] !== true) { - throw new Error(`Write probe rejected: ${String(response[3] ?? '')}`) + return undefined } return Date.now() - writeStartedAt } - let rtt = await runWrite() + let rtt: number | undefined - if (rtt === undefined && nip42AuthRequired) { - await authenticateIfNeeded() + try { rtt = await runWrite() + } catch { + return undefined + } + + if (rtt === undefined && nip42AuthRequired) { + try { + await authenticateIfNeeded() + rtt = await runWrite() + } catch { + return undefined + } } return rtt @@ -293,22 +314,33 @@ export const createNodeWebSocketProtocolConnector = (): WebSocketProtocolConnect void (async () => { try { await waitForOptionalAuthChallenge(remainingTimeoutMs()) + } catch { + // Best-effort: open RTT is still useful if optional auth wait fails. + } + + let rttReadMs: number | undefined + let rttWriteMs: number | undefined + + try { + rttReadMs = await measureReadRtt() + } catch { + rttReadMs = undefined + } - const rttReadMs = await measureReadRtt() - const rttWriteMs = await measureWriteRtt() - - finish(undefined, { - rttOpenMs: openAt! - startedAt, - rttReadMs, - rttWriteMs, - nip42AuthRequired: nip42AuthRequired || undefined, - nip42ChallengeObserved: authChallenge ? true : undefined, - address: target.wsUrl, - }) - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error) - finish(new Error(message)) + try { + rttWriteMs = await measureWriteRtt() + } catch { + rttWriteMs = undefined } + + finish(undefined, { + rttOpenMs: openAt! - startedAt, + rttReadMs, + rttWriteMs, + nip42AuthRequired: nip42AuthRequired || undefined, + nip42ChallengeObserved: authChallenge ? true : undefined, + address: target.wsUrl, + }) })() }) @@ -317,6 +349,16 @@ export const createNodeWebSocketProtocolConnector = (): WebSocketProtocolConnect }) setTimeout(() => { + if (openAt !== undefined) { + finish(undefined, { + rttOpenMs: openAt - startedAt, + nip42AuthRequired: nip42AuthRequired || undefined, + nip42ChallengeObserved: authChallenge ? true : undefined, + address: target.wsUrl, + }) + return + } + finish(new Error(`WebSocket probe timed out after ${timeoutMs}ms`)) }, timeoutMs).unref() }), From ff065ce8a587f84e4ea9f4f651d879d5edec3db4 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Sat, 3 Oct 2026 22:24:28 +0530 Subject: [PATCH 3/4] fix(nip66): address Greptile review on probes and discovery publish Parse Buffer WebSocket frames, keep read/write probes best-effort, avoid publishing kind 30166 for static mirrors, tighten NIP-11-derived tags and content, and only mark NIP-42 auth when probes require it. --- CONFIGURATION.md | 2 +- src/services/nip66-event-publisher.ts | 25 ++++++++++++++-- src/utils/nip66-events.ts | 17 ++++++++++- src/utils/nip66-nip11-tags.ts | 5 +++- src/utils/relay-probe-targets.ts | 33 ++++++++++++++++++++- src/utils/relay-probe/nip11-probe.ts | 12 +++----- src/utils/relay-probe/ws-protocol-probe.ts | 29 +++++++++++++++--- test/unit/utils/nip66-events.spec.ts | 7 +++++ test/unit/utils/nip66-nip11-tags.spec.ts | 23 ++++++++++++++ test/unit/utils/relay-probe-targets.spec.ts | 17 ++++++++++- test/unit/utils/ws-protocol-probe.spec.ts | 20 +++++++++++++ 11 files changed, 171 insertions(+), 19 deletions(-) create mode 100644 test/unit/utils/nip66-nip11-tags.spec.ts create mode 100644 test/unit/utils/ws-protocol-probe.spec.ts diff --git a/CONFIGURATION.md b/CONFIGURATION.md index c1792182..2570fd8a 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -225,7 +225,7 @@ The settings below are listed in alphabetical order by name. Please keep this ta | nip66.dnsCacheTtlSeconds | DNS cache TTL in seconds for repeated probe lookups of the same hostname. Defaults to 300. | | nip66.enabled | Enable NIP-66 relay monitoring. When true, starts a `relay-monitor` cluster worker that probes targets on an interval and stores the latest snapshot in Redis. Defaults to false. | | nip66.probeIntervalSeconds | Seconds between scheduled relay probe runs. Defaults to 3600. | -| nip66.targets | Public WebSocket URLs to probe (for example `wss://relay.example.com`). When empty, defaults to `info.relay_url`. Mirror addresses from `mirroring.static[]` are always probed in addition. | +| nip66.targets | Public WebSocket URLs to probe (for example `wss://relay.example.com`). When empty, defaults to `info.relay_url`. Addresses from `mirroring.static[]` are probed for the admin snapshot but are not published as kind `30166` discovery events. | | nip66.geohash | Optional NIP-52 geohash describing this monitor's network vantage. Published on kind `10166` monitor announcements when set. | | nip66.timeouts.dnsMs | DNS probe timeout in milliseconds. Defaults to 10000. | | nip66.timeouts.nip11Ms | NIP-11 fetch timeout in milliseconds. Defaults to 10000. | diff --git a/src/services/nip66-event-publisher.ts b/src/services/nip66-event-publisher.ts index 25e3ebbe..ee47408b 100644 --- a/src/services/nip66-event-publisher.ts +++ b/src/services/nip66-event-publisher.ts @@ -13,7 +13,8 @@ import { buildMonitorRelayListEvent, buildRelayDiscoveryEvent, } from '../utils/nip66-events' -import { filterValidProbeTargets } from '../utils/relay-probe-targets' +import { normalizeRelayUrlForDTag } from '../utils/nip66-events' +import { filterValidProbeTargets, resolvePublicProbeTargetKeys } from '../utils/relay-probe-targets' const logger = createLogger('nip66-event-publisher') @@ -45,11 +46,31 @@ export class Nip66EventPublisher implements INip66EventPublisher { await this.persistSignedEvent(buildMonitorAnnouncementEvent(settings, monitorPubkey, createdAt), privkey) + const publishableTargetKeys = resolvePublicProbeTargetKeys(settings) + let publishedTargets = 0 + for (const result of snapshot.results) { + let targetKey: string + + try { + targetKey = normalizeRelayUrlForDTag(result.target.relayUrl) + } catch { + continue + } + + if (!publishableTargetKeys.has(targetKey)) { + continue + } + await this.persistSignedEvent(buildRelayDiscoveryEvent(result, monitorPubkey, createdAt), privkey) + publishedTargets += 1 } - logger('published NIP-66 events for %d probe target(s)', snapshot.results.length) + logger( + 'published NIP-66 discovery events for %d public probe target(s) (%d probed overall)', + publishedTargets, + snapshot.results.length, + ) } private async ensureBootstrap( diff --git a/src/utils/nip66-events.ts b/src/utils/nip66-events.ts index ddf65b66..70e1b2b1 100644 --- a/src/utils/nip66-events.ts +++ b/src/utils/nip66-events.ts @@ -7,6 +7,21 @@ import { geohashSchema } from '../schemas/base-schema' import { appendNip11DiscoveryTags, appendWsProbeDiscoveryTags } from './nip66-nip11-tags' import { getEffectiveProbeIntervalSeconds } from './nip66-schedule' +/** Matches default kind 30166 content limit in resources/default-settings.yaml. */ +export const MAX_RELAY_DISCOVERY_CONTENT_LENGTH = 102_400 + +export const relayDiscoveryContentFromNip11 = (rawDocument?: string): string => { + if (!rawDocument) { + return '' + } + + if (rawDocument.length > MAX_RELAY_DISCOVERY_CONTENT_LENGTH) { + return '' + } + + return rawDocument +} + const appendDnsProbeTags = (tags: Tag[], dns: StoredProbeResult['dns']): void => { if (dns.status === 'skipped') { tags.push(['dns', 'skipped']) @@ -83,7 +98,7 @@ export const buildRelayDiscoveryEvent = ( const nip11Content = result.nip11.status === 'ok' && typeof result.nip11.data?.rawDocument === 'string' - ? result.nip11.data.rawDocument + ? relayDiscoveryContentFromNip11(result.nip11.data.rawDocument) : '' return { diff --git a/src/utils/nip66-nip11-tags.ts b/src/utils/nip66-nip11-tags.ts index 67dd475b..1d2c038f 100644 --- a/src/utils/nip66-nip11-tags.ts +++ b/src/utils/nip66-nip11-tags.ts @@ -27,7 +27,10 @@ export const appendNip11DiscoveryTags = (tags: Tag[], result: StoredProbeResult) requirementTag('auth', limitation?.authRequired), requirementTag('writes', limitation?.restrictedWrites), requirementTag('payment', limitation?.paymentRequired), - requirementTag('pow', (limitation?.minPowDifficulty ?? 0) > 0), + requirementTag( + 'pow', + limitation?.minPowDifficulty === undefined ? undefined : limitation.minPowDifficulty > 0, + ), ].filter((tag): tag is Tag => tag !== undefined) tags.push(...requirementTags) diff --git a/src/utils/relay-probe-targets.ts b/src/utils/relay-probe-targets.ts index e4d3e3bf..9212533d 100644 --- a/src/utils/relay-probe-targets.ts +++ b/src/utils/relay-probe-targets.ts @@ -25,7 +25,8 @@ const addUniqueTarget = (targets: string[], seen: Set, candidate: string targets.push(trimmed) } -export const resolveProbeTargets = (settings: Settings): string[] => { +/** Targets eligible for public kind 30166 publish (never includes mirroring.static peers). */ +export const resolvePublicProbeTargets = (settings: Settings): string[] => { const targets: string[] = [] const seen = new Set() const configured = settings.nip66?.targets?.map((target) => target.trim()).filter(Boolean) ?? [] @@ -38,6 +39,36 @@ export const resolveProbeTargets = (settings: Settings): string[] => { addUniqueTarget(targets, seen, settings.info?.relay_url) } + return targets +} + +export const resolvePublicProbeTargetKeys = (settings: Settings): Set => { + const keys = new Set() + + for (const target of resolvePublicProbeTargets(settings)) { + try { + keys.add(normalizeRelayUrlForDTag(target)) + } catch { + keys.add(target.toLowerCase()) + } + } + + return keys +} + +/** All probe targets: public targets plus configured static mirrors (operator snapshot only). */ +export const resolveProbeTargets = (settings: Settings): string[] => { + const targets = resolvePublicProbeTargets(settings) + const seen = new Set() + + for (const target of targets) { + try { + seen.add(normalizeRelayUrlForDTag(target)) + } catch { + seen.add(target.toLowerCase()) + } + } + for (const mirror of settings.mirroring?.static ?? []) { addUniqueTarget(targets, seen, mirror.address) } diff --git a/src/utils/relay-probe/nip11-probe.ts b/src/utils/relay-probe/nip11-probe.ts index 2784dc18..306fa466 100644 --- a/src/utils/relay-probe/nip11-probe.ts +++ b/src/utils/relay-probe/nip11-probe.ts @@ -116,13 +116,9 @@ export const createNodeNip11Fetcher = (): Nip11Fetcher => ({ } const limitation = parsed.data.limitation - const acceptedKinds = new Set(parsed.data.accepted_kinds ?? []) - - for (const publicationFee of parsed.data.fees?.publication ?? []) { - for (const kind of publicationFee.kinds ?? []) { - acceptedKinds.add(kind) - } - } + const acceptedKinds = parsed.data.accepted_kinds?.length + ? [...parsed.data.accepted_kinds].sort((a, b) => a - b) + : undefined return { statusCode: response.status, @@ -137,7 +133,7 @@ export const createNodeNip11Fetcher = (): Nip11Fetcher => ({ minPowDifficulty: limitation.min_pow_difficulty, } : undefined, - acceptedKinds: acceptedKinds.size > 0 ? [...acceptedKinds].sort((a, b) => a - b) : undefined, + acceptedKinds, rawDocument: JSON.stringify(parsed.data), } } catch (error: unknown) { diff --git a/src/utils/relay-probe/ws-protocol-probe.ts b/src/utils/relay-probe/ws-protocol-probe.ts index 2c3bc93e..e4e3d9b7 100644 --- a/src/utils/relay-probe/ws-protocol-probe.ts +++ b/src/utils/relay-probe/ws-protocol-probe.ts @@ -23,13 +23,35 @@ type NostrWireMessage = unknown[] const isAuthRequiredMessage = (message: string | undefined): boolean => typeof message === 'string' && message.toLowerCase().includes('auth-required') -const parseWireMessage = (raw: unknown): NostrWireMessage | undefined => { - if (typeof raw !== 'string') { +export const wireMessagePayload = (raw: unknown): string | undefined => { + if (typeof raw === 'string') { + return raw + } + + if (Buffer.isBuffer(raw)) { + return raw.toString('utf8') + } + + if (Array.isArray(raw) && raw.length > 0 && raw.every((part) => Buffer.isBuffer(part))) { + return Buffer.concat(raw).toString('utf8') + } + + if (raw instanceof ArrayBuffer) { + return Buffer.from(raw).toString('utf8') + } + + return undefined +} + +export const parseWireMessage = (raw: unknown): NostrWireMessage | undefined => { + const payload = wireMessagePayload(raw) + + if (!payload) { return undefined } try { - const parsed = JSON.parse(raw) as unknown + const parsed = JSON.parse(payload) as unknown return Array.isArray(parsed) ? parsed : undefined } catch { return undefined @@ -107,7 +129,6 @@ export const createNodeWebSocketProtocolConnector = (): WebSocketProtocolConnect if (message?.[0] === 'AUTH' && typeof message[1] === 'string') { authChallenge = message[1] - nip42AuthRequired = true } } diff --git a/test/unit/utils/nip66-events.spec.ts b/test/unit/utils/nip66-events.spec.ts index cc87731d..618f4330 100644 --- a/test/unit/utils/nip66-events.spec.ts +++ b/test/unit/utils/nip66-events.spec.ts @@ -8,7 +8,9 @@ import { buildMonitorProfileEvent, buildMonitorRelayListEvent, buildRelayDiscoveryEvent, + MAX_RELAY_DISCOVERY_CONTENT_LENGTH, normalizeRelayUrlForDTag, + relayDiscoveryContentFromNip11, } from '../../../src/utils/nip66-events' import { MIN_PROBE_INTERVAL_SECONDS } from '../../../src/utils/nip66-schedule' @@ -48,6 +50,11 @@ const storedProbeResult = (relayUrl = 'wss://Relay.Example.com:443/'): StoredPro }) as StoredProbeResult describe('nip66-events', () => { + it('drops oversized NIP-11 documents from discovery content', () => { + const oversized = 'x'.repeat(MAX_RELAY_DISCOVERY_CONTENT_LENGTH + 1) + expect(relayDiscoveryContentFromNip11(oversized)).to.equal('') + }) + it('normalizes relay URLs for the d tag', () => { expect(normalizeRelayUrlForDTag('wss://Relay.Example.com:443/')).to.equal('wss://relay.example.com/') expect(normalizeRelayUrlForDTag('ws://localhost:18808')).to.equal('ws://localhost:18808/') diff --git a/test/unit/utils/nip66-nip11-tags.spec.ts b/test/unit/utils/nip66-nip11-tags.spec.ts new file mode 100644 index 00000000..48e19447 --- /dev/null +++ b/test/unit/utils/nip66-nip11-tags.spec.ts @@ -0,0 +1,23 @@ +import { expect } from 'chai' + +import { appendNip11DiscoveryTags } from '../../../src/utils/nip66-nip11-tags' + +describe('nip66-nip11-tags', () => { + it('omits pow requirement tags when min_pow_difficulty is absent from NIP-11', () => { + const result = { + nip11: { + status: 'ok', + durationMs: 1, + data: { + statusCode: 200, + limitation: {}, + }, + }, + } as Parameters[1] + + const tags: Parameters[0] = [] + appendNip11DiscoveryTags(tags, result) + + expect(tags.some((tag) => tag[0] === 'R' && tag[1]?.includes('pow'))).to.equal(false) + }) +}) diff --git a/test/unit/utils/relay-probe-targets.spec.ts b/test/unit/utils/relay-probe-targets.spec.ts index c78b7a41..4fc27caa 100644 --- a/test/unit/utils/relay-probe-targets.spec.ts +++ b/test/unit/utils/relay-probe-targets.spec.ts @@ -1,7 +1,11 @@ import { expect } from 'chai' import { Settings } from '../../../src/@types/settings' -import { filterValidProbeTargets, resolveProbeTargets } from '../../../src/utils/relay-probe-targets' +import { + filterValidProbeTargets, + resolveProbeTargets, + resolvePublicProbeTargets, +} from '../../../src/utils/relay-probe-targets' describe('relay-probe-targets', () => { const baseSettings = { @@ -38,6 +42,17 @@ describe('relay-probe-targets', () => { expect(resolveProbeTargets(settings)).to.deep.equal(['wss://one.example', 'wss://two.example']) }) + it('does not include mirroring.static in public publish targets', () => { + const settings = { + ...baseSettings, + mirroring: { + static: [{ address: 'wss://mirror.example' }], + }, + } as Settings + + expect(resolvePublicProbeTargets(settings)).to.deep.equal(['wss://relay.example.com']) + }) + it('includes mirroring.static addresses in addition to configured targets', () => { const settings = { ...baseSettings, diff --git a/test/unit/utils/ws-protocol-probe.spec.ts b/test/unit/utils/ws-protocol-probe.spec.ts new file mode 100644 index 00000000..42e33445 --- /dev/null +++ b/test/unit/utils/ws-protocol-probe.spec.ts @@ -0,0 +1,20 @@ +import { expect } from 'chai' + +import { parseWireMessage, wireMessagePayload } from '../../../src/utils/relay-probe/ws-protocol-probe' + +describe('ws-protocol-probe wire parsing', () => { + it('parses JSON array messages from Buffer payloads', () => { + const payload = Buffer.from(JSON.stringify(['COUNT', 'nip66-probe-read', { count: 1 }]), 'utf8') + + expect(wireMessagePayload(payload)).to.be.a('string') + expect(parseWireMessage(payload)).to.deep.equal(['COUNT', 'nip66-probe-read', { count: 1 }]) + }) + + it('parses fragmented Buffer array payloads', () => { + const json = JSON.stringify(['OK', 'event-id', true, '']) + const partA = Buffer.from(json.slice(0, 8), 'utf8') + const partB = Buffer.from(json.slice(8), 'utf8') + + expect(parseWireMessage([partA, partB])).to.deep.equal(['OK', 'event-id', true, '']) + }) +}) From 1bb5df05a47609a2f74c1143780a1ad09b4fa034 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Sun, 4 Oct 2026 10:46:08 +0530 Subject: [PATCH 4/4] fix(nip66): keep read/write RTT on ws probe timeout --- src/utils/relay-probe/ws-protocol-probe.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/utils/relay-probe/ws-protocol-probe.ts b/src/utils/relay-probe/ws-protocol-probe.ts index e4e3d9b7..0c4bfa08 100644 --- a/src/utils/relay-probe/ws-protocol-probe.ts +++ b/src/utils/relay-probe/ws-protocol-probe.ts @@ -101,6 +101,8 @@ export const createNodeWebSocketProtocolConnector = (): WebSocketProtocolConnect const socket = new WebSocket(target.wsUrl, { handshakeTimeout: timeoutMs }) let settled = false let openAt: number | undefined + let rttReadMs: number | undefined + let rttWriteMs: number | undefined let authChallenge: string | undefined let nip42AuthRequired = false @@ -339,9 +341,6 @@ export const createNodeWebSocketProtocolConnector = (): WebSocketProtocolConnect // Best-effort: open RTT is still useful if optional auth wait fails. } - let rttReadMs: number | undefined - let rttWriteMs: number | undefined - try { rttReadMs = await measureReadRtt() } catch { @@ -373,6 +372,8 @@ export const createNodeWebSocketProtocolConnector = (): WebSocketProtocolConnect if (openAt !== undefined) { finish(undefined, { rttOpenMs: openAt - startedAt, + rttReadMs, + rttWriteMs, nip42AuthRequired: nip42AuthRequired || undefined, nip42ChallengeObserved: authChallenge ? true : undefined, address: target.wsUrl,