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..2570fd8a 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`. 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. |
| 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..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(
@@ -72,7 +93,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..70e1b2b1 100644
--- a/src/utils/nip66-events.ts
+++ b/src/utils/nip66-events.ts
@@ -3,8 +3,25 @@ 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'
+/** 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'])
@@ -74,18 +91,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'
+ ? relayDiscoveryContentFromNip11(result.nip11.data.rawDocument)
+ : ''
+
return {
kind: EventKinds.RELAY_DISCOVERY,
pubkey: monitorPubkey,
created_at: createdAt,
- content: '',
+ content: nip11Content,
tags,
}
}
@@ -100,14 +120,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 +153,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..1d2c038f
--- /dev/null
+++ b/src/utils/nip66-nip11-tags.ts
@@ -0,0 +1,65 @@
+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 === undefined ? undefined : limitation.minPowDifficulty > 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..9212533d 100644
--- a/src/utils/relay-probe-targets.ts
+++ b/src/utils/relay-probe-targets.ts
@@ -1,15 +1,79 @@
import { Settings } from '../@types/settings'
import { parseProbeTarget } from './relay-probe'
+import { normalizeRelayUrlForDTag } from './nip66-events'
-export const resolveProbeTargets = (settings: Settings): string[] => {
+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)
+}
+
+/** 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) ?? []
if (configured.length > 0) {
- return configured
+ for (const target of configured) {
+ addUniqueTarget(targets, seen, target)
+ }
+ } else {
+ 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)
}
- 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..306fa466 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,26 @@ export const createNodeNip11Fetcher = (): Nip11Fetcher => ({
throw new Error(`invalid NIP-11 document: ${reason}`)
}
+ const limitation = parsed.data.limitation
+ const acceptedKinds = parsed.data.accepted_kinds?.length
+ ? [...parsed.data.accepted_kinds].sort((a, b) => a - b)
+ : undefined
+
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,
+ 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..0c4bfa08
--- /dev/null
+++ b/src/utils/relay-probe/ws-protocol-probe.ts
@@ -0,0 +1,395 @@
+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')
+
+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(payload) 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 rttReadMs: number | undefined
+ let rttWriteMs: 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]
+ }
+ }
+
+ 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 phaseTimeoutMs = (): number => Math.max(500, Math.min(4_000, remainingTimeoutMs()))
+
+ 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,
+ phaseTimeoutMs(),
+ )
+
+ 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),
+ phaseTimeoutMs(),
+ )
+
+ if (response[0] === 'CLOSED' && isAuthRequiredMessage(String(response[2] ?? ''))) {
+ nip42AuthRequired = true
+ return undefined
+ }
+
+ if (response[0] !== 'COUNT') {
+ return undefined
+ }
+
+ return Date.now() - readStartedAt
+ }
+
+ let rtt: number | undefined
+
+ 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
+ }
+
+ 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,
+ phaseTimeoutMs(),
+ )
+
+ if (response[2] !== true && isAuthRequiredMessage(String(response[3] ?? ''))) {
+ nip42AuthRequired = true
+ return undefined
+ }
+
+ if (response[2] !== true) {
+ return undefined
+ }
+
+ return Date.now() - writeStartedAt
+ }
+
+ let rtt: number | undefined
+
+ try {
+ rtt = await runWrite()
+ } catch {
+ return undefined
+ }
+
+ if (rtt === undefined && nip42AuthRequired) {
+ try {
+ await authenticateIfNeeded()
+ rtt = await runWrite()
+ } catch {
+ return undefined
+ }
+ }
+
+ return rtt
+ }
+
+ socket.once('open', () => {
+ openAt = Date.now()
+
+ void (async () => {
+ try {
+ await waitForOptionalAuthChallenge(remainingTimeoutMs())
+ } catch {
+ // Best-effort: open RTT is still useful if optional auth wait fails.
+ }
+
+ try {
+ rttReadMs = await measureReadRtt()
+ } catch {
+ rttReadMs = undefined
+ }
+
+ try {
+ rttWriteMs = await measureWriteRtt()
+ } catch {
+ rttWriteMs = undefined
+ }
+
+ finish(undefined, {
+ rttOpenMs: openAt! - startedAt,
+ rttReadMs,
+ rttWriteMs,
+ nip42AuthRequired: nip42AuthRequired || undefined,
+ nip42ChallengeObserved: authChallenge ? true : undefined,
+ address: target.wsUrl,
+ })
+ })()
+ })
+
+ socket.once('error', (error) => {
+ finish(error instanceof Error ? error : new Error(String(error)))
+ })
+
+ setTimeout(() => {
+ if (openAt !== undefined) {
+ finish(undefined, {
+ rttOpenMs: openAt - startedAt,
+ rttReadMs,
+ rttWriteMs,
+ nip42AuthRequired: nip42AuthRequired || undefined,
+ nip42ChallengeObserved: authChallenge ? true : undefined,
+ address: target.wsUrl,
+ })
+ return
+ }
+
+ 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..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'
@@ -29,11 +31,30 @@ 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', () => {
+ 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/')
@@ -47,6 +68,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 +104,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 +118,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/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-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..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,46 @@ 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,
+ 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'])
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, ''])
+ })
+})