diff --git a/.changeset/admin-network-health-endpoint.md b/.changeset/admin-network-health-endpoint.md
new file mode 100644
index 00000000..ee9e8c30
--- /dev/null
+++ b/.changeset/admin-network-health-endpoint.md
@@ -0,0 +1,5 @@
+---
+"nostream": minor
+---
+
+feat(admin): add GET /admin/network-health endpoint
diff --git a/.changeset/admin-network-health-panel.md b/.changeset/admin-network-health-panel.md
new file mode 100644
index 00000000..333f1063
--- /dev/null
+++ b/.changeset/admin-network-health-panel.md
@@ -0,0 +1,7 @@
+---
+"nostream": minor
+---
+
+feat(admin): add Network Health panel to observability dashboard
+
+Adds a dashboard section that renders the latest NIP-66 probe snapshot with per-target DNS, TLS, WebSocket RTT, and NIP-11 status.
diff --git a/.changeset/nip66-publish-events.md b/.changeset/nip66-publish-events.md
new file mode 100644
index 00000000..e40a1df4
--- /dev/null
+++ b/.changeset/nip66-publish-events.md
@@ -0,0 +1,11 @@
+---
+"nostream": minor
+---
+
+feat(nip66): publish kind 30166 and 10166 relay health events after probe runs
+
+After each relay monitor probe run, sign and store NIP-66 relay discovery and monitor
+announcement events using the configured monitor identity, bootstrap kind 0/10002 on
+first run, and persist via the existing parameterized replaceable event path.
+
+Fixes #696
diff --git a/resources/admin/assets/dashboard.css b/resources/admin/assets/dashboard.css
index d5782f57..33efdd16 100644
--- a/resources/admin/assets/dashboard.css
+++ b/resources/admin/assets/dashboard.css
@@ -754,6 +754,71 @@
}
}
+.network-health-results {
+ display: grid;
+ gap: 0.75rem;
+}
+
+.network-health-target {
+ background: var(--panel);
+ border: 1px solid var(--panel-border);
+ padding: 0.85rem 1rem;
+}
+
+.network-health-target-header {
+ align-items: flex-start;
+ display: flex;
+ gap: 0.65rem;
+ justify-content: space-between;
+ margin-bottom: 0.65rem;
+}
+
+.network-health-target-url {
+ color: var(--text);
+ flex: 1;
+ font-size: 0.85rem;
+ font-weight: 600;
+ margin-bottom: 0;
+ word-break: break-all;
+}
+
+.network-health-network-type {
+ background: var(--panel-border);
+ border: 1px solid var(--panel-border);
+ color: var(--label);
+ font-size: 0.62rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ padding: 0.15rem 0.4rem;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
+.network-health-checks {
+ display: grid;
+ gap: 0.45rem;
+ grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
+}
+
+.network-health-check {
+ border: 1px solid var(--panel-border);
+ padding: 0.45rem 0.55rem;
+}
+
+.network-health-check-label {
+ color: var(--label);
+ font-size: 0.65rem;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ margin-bottom: 0.15rem;
+ text-transform: uppercase;
+}
+
+.network-health-check-value {
+ font-size: 0.78rem;
+ margin-bottom: 0;
+}
+
@media (max-width: 768px) {
.admin-dashboard .metric-value {
font-size: 1rem;
diff --git a/resources/admin/assets/dashboard.js b/resources/admin/assets/dashboard.js
index 594dce51..8420d266 100644
--- a/resources/admin/assets/dashboard.js
+++ b/resources/admin/assets/dashboard.js
@@ -35,6 +35,11 @@
const settingsDiffContent = document.getElementById('settings-diff-content')
const settingsDiffSummary = document.getElementById('settings-diff-summary')
const dashboardViews = document.querySelectorAll('.dashboard-view')
+ const networkHealthSync = document.getElementById('network-health-sync')
+ const networkHealthEmpty = document.getElementById('network-health-empty')
+ const networkHealthSummary = document.getElementById('network-health-summary')
+ const networkHealthResults = document.getElementById('network-health-results')
+ const networkHealthRunAt = document.getElementById('network-health-run-at')
let settingsLoaded = false
let settingsLoading = false
@@ -52,6 +57,8 @@
let relativeTimeTimer
let staleCheckTimer
const staleThresholdMs = 15000
+ let networkHealthPollTimer
+ const networkHealthPollIntervalMs = 60000
const statusClasses = ['status-ok', 'status-degraded', 'status-unavailable', 'status-down', 'status-no-data']
@@ -81,6 +88,16 @@
parseError: '[ERR]',
reconnect: '[RETRY]',
},
+ probeRun: {
+ ok: '[OK]',
+ partial: '[WARN]',
+ failed: '[FAULT]',
+ },
+ probeCheck: {
+ ok: '[OK]',
+ error: '[ERR]',
+ skipped: '[SKIP]',
+ },
}
const getTheme = () => {
@@ -300,6 +317,7 @@
const showLogin = () => {
stopMetricsStream()
+ stopNetworkHealthPolling()
setNavOpen(false)
loginPanel.classList.remove('d-none')
dashboardPanel.classList.add('d-none')
@@ -341,6 +359,230 @@
})
startMetricsStream()
+ void refreshNetworkHealth()
+ startNetworkHealthPolling()
+ }
+
+ const stopNetworkHealthPolling = () => {
+ if (networkHealthPollTimer) {
+ clearInterval(networkHealthPollTimer)
+ networkHealthPollTimer = undefined
+ }
+ }
+
+ const startNetworkHealthPolling = () => {
+ stopNetworkHealthPolling()
+ networkHealthPollTimer = setInterval(() => {
+ void refreshNetworkHealth()
+ }, networkHealthPollIntervalMs)
+ }
+
+ const setNetworkHealthSyncLine = (message) => {
+ if (!networkHealthSync) {
+ return
+ }
+
+ networkHealthSync.innerHTML = `> probes: ${message}`
+ }
+
+ const probeCheckStatusClass = (status, options = {}) => {
+ if (status === 'ok') {
+ if (typeof options.tlsDaysUntilExpiry === 'number' && options.tlsDaysUntilExpiry < 14) {
+ return 'status-degraded'
+ }
+
+ return 'status-ok'
+ }
+ if (status === 'error') {
+ return 'status-down'
+ }
+
+ return 'status-no-data'
+ }
+
+ const formatProbeCheckDetail = (check, formatter, options = {}) => {
+ const label = statusLabels.probeCheck[check?.status] ?? statusLabels.probeCheck.skipped
+ const detail = typeof formatter === 'function' && check?.status === 'ok' ? formatter(check.data) : check?.error
+ const className = probeCheckStatusClass(check?.status, options)
+
+ return {
+ label,
+ className,
+ detail: detail ? String(detail) : '',
+ }
+ }
+
+ const renderNetworkHealthSnapshot = (snapshot) => {
+ if (!networkHealthEmpty || !networkHealthSummary || !networkHealthResults) {
+ return
+ }
+
+ if (!snapshot) {
+ networkHealthEmpty.classList.remove('d-none')
+ networkHealthSummary.classList.add('d-none')
+ networkHealthResults.classList.add('d-none')
+ networkHealthResults.replaceChildren()
+ setNetworkHealthSyncLine('no probe snapshot available')
+ return
+ }
+
+ networkHealthEmpty.classList.add('d-none')
+ networkHealthSummary.classList.remove('d-none')
+ networkHealthResults.classList.remove('d-none')
+
+ const runStatus = snapshot.status ?? 'failed'
+ const runStatusClass =
+ runStatus === 'ok' ? 'status-ok' : runStatus === 'partial' ? 'status-degraded' : 'status-down'
+ setStatusText(
+ 'network-health-run-status',
+ statusLabels.probeRun[runStatus] ?? statusLabels.probeRun.failed,
+ runStatusClass,
+ )
+ setMetricValue('network-health-target-count', Array.isArray(snapshot.results) ? snapshot.results.length : 0)
+
+ const runAtMs = Date.parse(snapshot.runAt)
+ if (Number.isFinite(runAtMs)) {
+ networkHealthRunAt.innerHTML = `${new Date(runAtMs).toISOString()}`
+ setNetworkHealthSyncLine(`last updated ${formatRelativeTime(runAtMs)}`)
+ } else {
+ networkHealthRunAt.innerHTML = '—'
+ setNetworkHealthSyncLine('snapshot received')
+ }
+
+ networkHealthResults.replaceChildren()
+
+ if (!Array.isArray(snapshot.results) || snapshot.results.length === 0) {
+ const empty = document.createElement('p')
+ empty.className = 'admin-muted small mb-0'
+ empty.textContent = 'Probe run completed with no target results.'
+ networkHealthResults.appendChild(empty)
+ return
+ }
+
+ snapshot.results.forEach((result) => {
+ const card = document.createElement('article')
+ card.className = 'network-health-target'
+
+ const header = document.createElement('div')
+ header.className = 'network-health-target-header'
+
+ const title = document.createElement('p')
+ title.className = 'network-health-target-url mb-0'
+ title.textContent = result?.target?.relayUrl ?? result?.target?.wsUrl ?? 'Unknown target'
+ header.appendChild(title)
+
+ const networkType = result?.target?.networkType
+ if (networkType) {
+ const badge = document.createElement('span')
+ badge.className = 'network-health-network-type'
+ badge.textContent = networkType
+ header.appendChild(badge)
+ }
+
+ card.appendChild(header)
+
+ const checks = document.createElement('div')
+ checks.className = 'network-health-checks'
+
+ const dns = formatProbeCheckDetail(result.dns, (data) => {
+ const records = Array.isArray(data?.records) ? data.records : []
+
+ if (records.length === 0) {
+ return 'no records'
+ }
+
+ const preview = records.slice(0, 3).map((record) => {
+ const ttl = typeof record?.ttl === 'number' ? ` TTL ${record.ttl}` : ''
+ return `${record.type} ${record.value}${ttl}`
+ })
+
+ if (records.length > 3) {
+ preview.push(`+${records.length - 3} more`)
+ }
+
+ return preview.join('; ')
+ })
+ const tlsDaysUntilExpiry =
+ result.tls?.status === 'ok' && typeof result.tls?.data?.daysUntilExpiry === 'number'
+ ? result.tls.data.daysUntilExpiry
+ : undefined
+ const tls = formatProbeCheckDetail(
+ result.tls,
+ (data) => {
+ if (typeof data?.daysUntilExpiry === 'number') {
+ return `${data.daysUntilExpiry}d remaining`
+ }
+
+ return data?.issuer ?? 'valid'
+ },
+ { tlsDaysUntilExpiry },
+ )
+ const wsRtt = formatProbeCheckDetail(result.wsRtt, (data) => `${data.rttOpenMs} ms`)
+ const nip11 = formatProbeCheckDetail(result.nip11, (data) => {
+ const name = data?.name ? ` ${data.name}` : ''
+ const supportedNips = Array.isArray(data?.supportedNips) ? data.supportedNips : null
+ const nip66Warning =
+ supportedNips && !supportedNips.includes(66) ? ' · NIP-66 not in supported_nips' : ''
+
+ return `HTTP ${data.statusCode}${name}${nip66Warning}`
+ })
+
+ if (
+ result.nip11?.status === 'ok' &&
+ Array.isArray(result.nip11?.data?.supportedNips) &&
+ !result.nip11.data.supportedNips.includes(66)
+ ) {
+ nip11.className = 'status-degraded'
+ }
+
+ ;[
+ ['DNS', dns],
+ ['TLS', tls],
+ ['WS RTT', wsRtt],
+ ['NIP-11', nip11],
+ ].forEach(([name, check]) => {
+ const item = document.createElement('div')
+ item.className = 'network-health-check'
+
+ const label = document.createElement('p')
+ label.className = 'network-health-check-label mb-0'
+ label.textContent = name
+
+ const value = document.createElement('p')
+ value.className = `network-health-check-value ${check.className} mb-0`
+ value.textContent = check.detail ? `${check.label} · ${check.detail}` : check.label
+
+ item.appendChild(label)
+ item.appendChild(value)
+ checks.appendChild(item)
+ })
+
+ card.appendChild(checks)
+ networkHealthResults.appendChild(card)
+ })
+ }
+
+ const refreshNetworkHealth = async () => {
+ try {
+ const response = await fetch(`${adminBase}/network-health`, {
+ credentials: 'include',
+ })
+
+ if (response.status === 401) {
+ showLogin()
+ return
+ }
+
+ if (!response.ok) {
+ setNetworkHealthSyncLine('failed to load probe snapshot')
+ return
+ }
+
+ const body = await response.json()
+ renderNetworkHealthSnapshot(body.snapshot ?? null)
+ } catch {
+ setNetworkHealthSyncLine('network error while loading probes')
+ }
}
const parsePathTokens = (path) => {
diff --git a/resources/admin/dashboard.html b/resources/admin/dashboard.html
index ee74792e..9779d6bc 100644
--- a/resources/admin/dashboard.html
+++ b/resources/admin/dashboard.html
@@ -94,6 +94,33 @@
Health
+
+ Network Health
+ > probes: waiting for snapshot…
+ No external probe results yet. Enable NIP-66 relay monitoring to populate this panel.
+
+
+
+
RUN Probe status
+
Pending
+
+
+
+
+
+
TS Last probe run
+
—
+
+
+
+
+
+
Throughput
diff --git a/src/app/relay-monitor-worker.ts b/src/app/relay-monitor-worker.ts
index 76aa9506..48097f69 100644
--- a/src/app/relay-monitor-worker.ts
+++ b/src/app/relay-monitor-worker.ts
@@ -2,17 +2,16 @@ import { IRunnable } from '../@types/base'
import { IRelayProbeSnapshotStore, RelayProbeRunSnapshot } from '../@types/relay-probe-snapshot'
import { Settings } from '../@types/settings'
import { createLogger } from '../factories/logger-factory'
+import { INip66EventPublisher } from '../services/nip66-event-publisher'
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 { runProbe } from '../utils/relay-probe'
import { ProbeOptions, ProbeResult } from '../utils/relay-probe/types'
const logger = createLogger('relay-monitor-worker')
-const DEFAULT_PROBE_INTERVAL_SECONDS = 3600
-const MIN_PROBE_INTERVAL_SECONDS = 60
-
export type RunProbeFn = (relayUrl: string, options?: ProbeOptions) => Promise
export const buildProbeOptions = (settings: Settings): ProbeOptions => {
@@ -24,12 +23,7 @@ export const buildProbeOptions = (settings: Settings): ProbeOptions => {
}
}
-export const getProbeIntervalMs = (settings: Settings): number => {
- const configured = settings.nip66?.probeIntervalSeconds ?? DEFAULT_PROBE_INTERVAL_SECONDS
- const intervalSeconds = Math.max(configured, MIN_PROBE_INTERVAL_SECONDS)
-
- return intervalSeconds * 1000
-}
+export { getProbeIntervalMs } from '../utils/nip66-schedule'
export class RelayMonitorWorker implements IRunnable {
private interval: NodeJS.Timeout | undefined
@@ -40,6 +34,7 @@ export class RelayMonitorWorker implements IRunnable {
private readonly settings: () => Settings,
private readonly snapshotStore: IRelayProbeSnapshotStore,
private readonly probeRunner: RunProbeFn = runProbe,
+ private readonly eventPublisher?: INip66EventPublisher,
) {
this.process
.on('SIGINT', this.onExit.bind(this))
@@ -128,13 +123,18 @@ export class RelayMonitorWorker implements IRunnable {
status: deriveRelayProbeRunStatus(results),
}
- const expirySeconds = Math.max(
- (currentSettings.nip66?.probeIntervalSeconds ?? DEFAULT_PROBE_INTERVAL_SECONDS) * 2,
- MIN_PROBE_INTERVAL_SECONDS * 2,
- )
+ const expirySeconds = getEffectiveProbeIntervalSeconds(currentSettings) * 2
await this.snapshotStore.saveLatest(snapshot, expirySeconds)
logger('saved probe snapshot for %d target(s) with status %s', valid.length, snapshot.status)
+
+ if (this.eventPublisher) {
+ try {
+ await this.eventPublisher.publishAfterProbe(snapshot, currentSettings)
+ } catch (error) {
+ logger.error('failed to publish NIP-66 events: %o', error)
+ }
+ }
}
private onError(error: Error) {
diff --git a/src/constants/base.ts b/src/constants/base.ts
index f8ebd71a..636a163e 100644
--- a/src/constants/base.ts
+++ b/src/constants/base.ts
@@ -51,6 +51,8 @@ export enum EventKinds {
REPLACEABLE_FIRST = 10000,
// NIP-65: Relay List Metadata
RELAY_LIST = 10002,
+ // NIP-66: Relay monitor announcement
+ RELAY_MONITOR_ANNOUNCEMENT = 10166,
// Marmot Protocol MIP-00: KeyPackage Relay List
MARMOT_KEY_PACKAGE_RELAY_LIST = 10051,
// NIP-43: Membership List
@@ -69,6 +71,8 @@ export enum EventKinds {
EPHEMERAL_LAST = 29999,
// Parameterized replaceable events
PARAMETERIZED_REPLACEABLE_FIRST = 30000,
+ // NIP-66: Relay discovery
+ RELAY_DISCOVERY = 30166,
// Marmot Protocol MIP-00: KeyPackage (addressable, replaces legacy 443)
MARMOT_KEY_PACKAGE = 30443,
// NIP-89: Recommended Application Handlers
diff --git a/src/controllers/admin/get-network-health-controller.ts b/src/controllers/admin/get-network-health-controller.ts
new file mode 100644
index 00000000..3d9f2622
--- /dev/null
+++ b/src/controllers/admin/get-network-health-controller.ts
@@ -0,0 +1,14 @@
+import { Request, Response } from 'express'
+
+import { IController } from '../../@types/controllers'
+import { IRelayProbeSnapshotStore } from '../../@types/relay-probe-snapshot'
+
+export class GetAdminNetworkHealthController implements IController {
+ public constructor(private readonly snapshotStore: IRelayProbeSnapshotStore) {}
+
+ public async handleRequest(_request: Request, response: Response): Promise {
+ const snapshot = await this.snapshotStore.getLatest()
+
+ response.status(200).setHeader('content-type', 'application/json').send({ snapshot })
+ }
+}
diff --git a/src/factories/controllers/get-admin-network-health-controller-factory.ts b/src/factories/controllers/get-admin-network-health-controller-factory.ts
new file mode 100644
index 00000000..024dd828
--- /dev/null
+++ b/src/factories/controllers/get-admin-network-health-controller-factory.ts
@@ -0,0 +1,11 @@
+import { RedisAdapter } from '../../adapters/redis-adapter'
+import { IController } from '../../@types/controllers'
+import { getCacheClient } from '../../cache/client'
+import { GetAdminNetworkHealthController } from '../../controllers/admin/get-network-health-controller'
+import { RelayProbeSnapshotStore } from '../../utils/relay-probe-snapshot'
+
+export const createGetAdminNetworkHealthController = (): IController => {
+ const snapshotStore = new RelayProbeSnapshotStore(new RedisAdapter(getCacheClient()))
+
+ return new GetAdminNetworkHealthController(snapshotStore)
+}
diff --git a/src/factories/relay-monitor-worker-factory.ts b/src/factories/relay-monitor-worker-factory.ts
index 0fc42248..403bb434 100644
--- a/src/factories/relay-monitor-worker-factory.ts
+++ b/src/factories/relay-monitor-worker-factory.ts
@@ -1,11 +1,18 @@
import { RedisAdapter } from '../adapters/redis-adapter'
import { RelayMonitorWorker } from '../app/relay-monitor-worker'
import { getCacheClient } from '../cache/client'
+import { getMasterDbClient, getReadReplicaDbClient } from '../database/client'
import { createSettings } from './settings-factory'
+import { EventRepository } from '../repositories/event-repository'
+import { Nip66EventPublisher } from '../services/nip66-event-publisher'
import { RelayProbeSnapshotStore } from '../utils/relay-probe-snapshot'
+import { runProbe } from '../utils/relay-probe'
export const relayMonitorWorkerFactory = () => {
- const snapshotStore = new RelayProbeSnapshotStore(new RedisAdapter(getCacheClient()))
+ const cache = new RedisAdapter(getCacheClient())
+ const snapshotStore = new RelayProbeSnapshotStore(cache)
+ const eventRepository = new EventRepository(getMasterDbClient(), getReadReplicaDbClient(), createSettings)
+ const eventPublisher = new Nip66EventPublisher(eventRepository, cache)
- return new RelayMonitorWorker(process, createSettings, snapshotStore)
+ return new RelayMonitorWorker(process, createSettings, snapshotStore, runProbe, eventPublisher)
}
diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts
index c52f149d..a3fbb021 100644
--- a/src/routes/admin/index.ts
+++ b/src/routes/admin/index.ts
@@ -2,6 +2,7 @@ import express, { json, Router } from 'express'
import { createGetAdminHealthController } from '../../factories/controllers/get-admin-health-controller-factory'
import { createGetAdminMetricsController } from '../../factories/controllers/get-admin-metrics-controller-factory'
+import { createGetAdminNetworkHealthController } from '../../factories/controllers/get-admin-network-health-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'
@@ -56,6 +57,12 @@ router.get(
adminAuthMiddleware,
withAdminController(createGetAdminMetricsController),
)
+router.get(
+ '/network-health',
+ adminRateLimitMiddleware,
+ adminAuthMiddleware,
+ withAdminController(createGetAdminNetworkHealthController),
+)
router.get(
'/settings',
adminRateLimitMiddleware,
diff --git a/src/services/nip66-event-publisher.ts b/src/services/nip66-event-publisher.ts
new file mode 100644
index 00000000..9aa54983
--- /dev/null
+++ b/src/services/nip66-event-publisher.ts
@@ -0,0 +1,103 @@
+import { ICacheAdapter } from '../@types/adapters'
+import { Event, ParameterizedReplaceableEvent, UnidentifiedEvent } from '../@types/event'
+import { RelayProbeRunSnapshot } from '../@types/relay-probe-snapshot'
+import { IEventRepository } from '../@types/repositories'
+import { Settings } from '../@types/settings'
+import { EventDeduplicationMetadataKey, EventTags } from '../constants/base'
+import { createLogger } from '../factories/logger-factory'
+import { broadcastEvent, getPublicKey, identifyEvent, isParameterizedReplaceableEvent, signEvent } from '../utils/event'
+import { getMonitorPrivateKey } from '../utils/monitor-identity'
+import {
+ buildMonitorAnnouncementEvent,
+ buildMonitorProfileEvent,
+ buildMonitorRelayListEvent,
+ buildRelayDiscoveryEvent,
+} from '../utils/nip66-events'
+import { resolveProbeTargets } from '../utils/relay-probe-targets'
+
+const logger = createLogger('nip66-event-publisher')
+
+export const NIP66_MONITOR_BOOTSTRAPPED_KEY = 'nip66:monitor:bootstrapped'
+export const NIP66_MONITOR_BOOTSTRAP_TTL_SECONDS = 30 * 24 * 60 * 60
+
+export interface INip66EventPublisher {
+ publishAfterProbe(snapshot: RelayProbeRunSnapshot, settings: Settings): Promise
+}
+
+export class Nip66EventPublisher implements INip66EventPublisher {
+ public constructor(
+ private readonly eventRepository: IEventRepository,
+ private readonly cache: ICacheAdapter,
+ ) {}
+
+ public async publishAfterProbe(snapshot: RelayProbeRunSnapshot, settings: Settings): Promise {
+ const privkey = getMonitorPrivateKey()
+
+ if (!privkey) {
+ logger.warn('MONITOR_PRIVATE_KEY is not configured; skipping NIP-66 event publish')
+ return
+ }
+
+ const monitorPubkey = getPublicKey(privkey)
+ const createdAt = Math.floor(Date.now() / 1000)
+
+ await this.ensureBootstrap(monitorPubkey, settings, privkey, createdAt)
+
+ await this.persistSignedEvent(buildMonitorAnnouncementEvent(settings, monitorPubkey, createdAt), privkey)
+
+ for (const result of snapshot.results) {
+ await this.persistSignedEvent(buildRelayDiscoveryEvent(result, monitorPubkey, createdAt), privkey)
+ }
+
+ logger('published NIP-66 events for %d probe target(s)', snapshot.results.length)
+ }
+
+ private async ensureBootstrap(
+ monitorPubkey: string,
+ settings: Settings,
+ privkey: string,
+ createdAt: number,
+ ): Promise {
+ const bootstrapped = await this.cache.getKey(NIP66_MONITOR_BOOTSTRAPPED_KEY)
+
+ if (bootstrapped) {
+ return
+ }
+
+ const relayUrl = settings.info?.relay_url?.trim() || resolveProbeTargets(settings)[0]
+
+ if (!relayUrl) {
+ logger.warn('no relay URL available for NIP-66 bootstrap relay list; skipping kind 10002 publish')
+ return
+ }
+
+ await this.persistSignedEvent(buildMonitorProfileEvent(monitorPubkey, createdAt), privkey)
+ await this.persistSignedEvent(buildMonitorRelayListEvent(relayUrl, monitorPubkey, createdAt), privkey)
+
+ await this.cache.setKey(NIP66_MONITOR_BOOTSTRAPPED_KEY, monitorPubkey, NIP66_MONITOR_BOOTSTRAP_TTL_SECONDS)
+ logger('bootstrapped NIP-66 monitor identity for pubkey %s', monitorPubkey)
+ }
+
+ private async persistSignedEvent(unsigned: UnidentifiedEvent, privkey: string): Promise {
+ const signed = await signEvent(privkey)(await identifyEvent(unsigned))
+ let count: number
+
+ if (isParameterizedReplaceableEvent(signed)) {
+ const [, deduplication] = signed.tags.find((tag) => tag.length >= 2 && tag[0] === EventTags.Deduplication) ?? [
+ null,
+ '',
+ ]
+
+ count = await this.eventRepository.upsert({
+ ...signed,
+ [EventDeduplicationMetadataKey]: deduplication ? [deduplication] : [''],
+ } as ParameterizedReplaceableEvent)
+ } else {
+ count = await this.eventRepository.upsert(signed as Event)
+ }
+
+ if (count) {
+ await broadcastEvent(signed)
+ }
+ }
+}
diff --git a/src/utils/monitor-identity.ts b/src/utils/monitor-identity.ts
new file mode 100644
index 00000000..16daca7a
--- /dev/null
+++ b/src/utils/monitor-identity.ts
@@ -0,0 +1,21 @@
+let monitorPrivateKeyCache: string | undefined
+
+export const getMonitorPrivateKey = (): string | undefined => {
+ if (monitorPrivateKeyCache) {
+ return monitorPrivateKeyCache
+ }
+
+ const configured = process.env.MONITOR_PRIVATE_KEY?.trim()
+
+ if (!configured) {
+ return undefined
+ }
+
+ monitorPrivateKeyCache = configured
+
+ return monitorPrivateKeyCache
+}
+
+export const resetMonitorPrivateKeyCache = (): void => {
+ monitorPrivateKeyCache = undefined
+}
diff --git a/src/utils/nip66-events.ts b/src/utils/nip66-events.ts
new file mode 100644
index 00000000..ee9c6977
--- /dev/null
+++ b/src/utils/nip66-events.ts
@@ -0,0 +1,112 @@
+import { UnidentifiedEvent } from '../@types/event'
+import { Tag } from '../@types/base'
+import { StoredProbeResult } from '../@types/relay-probe-snapshot'
+import { Settings } from '../@types/settings'
+import { EventKinds, EventTags } from '../constants/base'
+import { getEffectiveProbeIntervalSeconds } from './nip66-schedule'
+
+export const normalizeRelayUrlForDTag = (relayUrl: string): string => {
+ const parsed = new URL(relayUrl)
+ parsed.protocol = parsed.protocol.toLowerCase()
+ parsed.hostname = parsed.hostname.toLowerCase()
+
+ if (
+ (parsed.protocol === 'wss:' && parsed.port === '443') ||
+ (parsed.protocol === 'ws:' && parsed.port === '80')
+ ) {
+ parsed.port = ''
+ }
+
+ let normalized = parsed.toString()
+
+ if ((parsed.pathname === '/' || parsed.pathname === '') && !normalized.endsWith('/')) {
+ normalized = `${normalized}/`
+ }
+
+ return normalized
+}
+
+export const buildRelayDiscoveryEvent = (
+ result: StoredProbeResult,
+ monitorPubkey: string,
+ createdAt: number,
+): UnidentifiedEvent => {
+ const tags: Tag[] = [
+ [EventTags.Deduplication, normalizeRelayUrlForDTag(result.target.relayUrl)],
+ ['n', result.target.networkType],
+ ]
+
+ if (result.wsRtt.status === 'ok' && typeof result.wsRtt.data?.rttOpenMs === 'number') {
+ tags.push(['rtt-open', String(result.wsRtt.data.rttOpenMs)])
+ }
+
+ return {
+ kind: EventKinds.RELAY_DISCOVERY,
+ pubkey: monitorPubkey,
+ created_at: createdAt,
+ content: '',
+ tags,
+ }
+}
+
+export const buildMonitorAnnouncementEvent = (
+ settings: Settings,
+ monitorPubkey: string,
+ createdAt: number,
+): UnidentifiedEvent => {
+ const nip66 = settings.nip66
+ const timeouts = nip66?.timeouts
+
+ const tags: Tag[] = [
+ ['frequency', String(getEffectiveProbeIntervalSeconds(settings))],
+ ['c', 'ws'],
+ ['c', 'nip11'],
+ ['c', 'ssl'],
+ ['c', 'dns'],
+ ]
+
+ if (timeouts) {
+ tags.push(['timeout', 'open', String(timeouts.wsRttMs)])
+ tags.push(['timeout', 'nip11', String(timeouts.nip11Ms)])
+ tags.push(['timeout', 'dns', String(timeouts.dnsMs)])
+ tags.push(['timeout', 'ssl', String(timeouts.tlsMs)])
+ }
+
+ return {
+ kind: EventKinds.RELAY_MONITOR_ANNOUNCEMENT,
+ pubkey: monitorPubkey,
+ created_at: createdAt,
+ content: '',
+ tags,
+ }
+}
+
+export const buildMonitorProfileEvent = (monitorPubkey: string, createdAt: number): UnidentifiedEvent => {
+ 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.',
+ }),
+ tags: [],
+ }
+}
+
+export const buildMonitorRelayListEvent = (
+ relayUrl: string,
+ monitorPubkey: string,
+ createdAt: number,
+): UnidentifiedEvent => {
+ return {
+ kind: EventKinds.RELAY_LIST,
+ pubkey: monitorPubkey,
+ created_at: createdAt,
+ content: '',
+ tags: [
+ [EventTags.Relay, relayUrl, 'read'],
+ [EventTags.Relay, relayUrl, 'write'],
+ ],
+ }
+}
diff --git a/src/utils/nip66-schedule.ts b/src/utils/nip66-schedule.ts
new file mode 100644
index 00000000..aaf7aff2
--- /dev/null
+++ b/src/utils/nip66-schedule.ts
@@ -0,0 +1,14 @@
+import { Settings } from '../@types/settings'
+
+export const DEFAULT_PROBE_INTERVAL_SECONDS = 3600
+export const MIN_PROBE_INTERVAL_SECONDS = 60
+
+export const getEffectiveProbeIntervalSeconds = (settings: Settings): number => {
+ const configured = settings.nip66?.probeIntervalSeconds ?? DEFAULT_PROBE_INTERVAL_SECONDS
+
+ return Math.max(configured, MIN_PROBE_INTERVAL_SECONDS)
+}
+
+export const getProbeIntervalMs = (settings: Settings): number => {
+ return getEffectiveProbeIntervalSeconds(settings) * 1000
+}
diff --git a/src/utils/relay-probe/nip11-probe.ts b/src/utils/relay-probe/nip11-probe.ts
index 2f63c7b6..59f7f5ec 100644
--- a/src/utils/relay-probe/nip11-probe.ts
+++ b/src/utils/relay-probe/nip11-probe.ts
@@ -11,6 +11,7 @@ const nip11DocumentSchema = z
.object({
name: z.string().optional(),
pubkey: pubkeySchema.optional(),
+ supported_nips: z.array(z.number().int().positive()).optional(),
})
.passthrough()
@@ -96,6 +97,7 @@ export const createNodeNip11Fetcher = (): Nip11Fetcher => ({
statusCode: response.status,
name: parsed.data.name,
pubkey: parsed.data.pubkey,
+ supportedNips: parsed.data.supported_nips,
}
} catch (error: unknown) {
const axiosError = error as AxiosError
diff --git a/src/utils/relay-probe/types.ts b/src/utils/relay-probe/types.ts
index 955205b2..3ef22328 100644
--- a/src/utils/relay-probe/types.ts
+++ b/src/utils/relay-probe/types.ts
@@ -51,6 +51,7 @@ export interface Nip11Result {
statusCode: number
name?: string
pubkey?: string
+ supportedNips?: number[]
}
export interface ProbeResult {
diff --git a/test/integration/features/nip-66/nip-66.feature b/test/integration/features/nip-66/nip-66.feature
index e054c428..d5807759 100644
--- a/test/integration/features/nip-66/nip-66.feature
+++ b/test/integration/features/nip-66/nip-66.feature
@@ -25,3 +25,10 @@ Feature: NIP-66 relay monitoring
When the relay monitor worker completes a probe run
Then the latest probe snapshot in Redis has status "ok"
And the snapshot includes probe results for "ws://localhost:18808"
+
+ Scenario: probe run publishes kind 30166 events
+ Given NIP-66 relay monitoring is enabled
+ And the NIP-66 monitor private key is configured
+ And the probe target is "ws://localhost:18808"
+ When the relay monitor worker completes a probe run
+ Then a kind 30166 event is stored for the monitor identity
diff --git a/test/integration/features/nip-66/nip-66.feature.ts b/test/integration/features/nip-66/nip-66.feature.ts
index af9e1e85..336e47e8 100644
--- a/test/integration/features/nip-66/nip-66.feature.ts
+++ b/test/integration/features/nip-66/nip-66.feature.ts
@@ -8,11 +8,19 @@ import { RelayProbeRunSnapshot } from '../../../../src/@types/relay-probe-snapsh
import { RedisAdapter } from '../../../../src/adapters/redis-adapter'
import { RelayMonitorWorker } from '../../../../src/app/relay-monitor-worker'
import { getCacheClient } from '../../../../src/cache/client'
+import { EventKinds } from '../../../../src/constants/base'
+import { getMasterDbClient, getReadReplicaDbClient } from '../../../../src/database/client'
import { Settings } from '../../../../src/@types/settings'
+import { EventRepository } from '../../../../src/repositories/event-repository'
+import { Nip66EventPublisher, NIP66_MONITOR_BOOTSTRAPPED_KEY } from '../../../../src/services/nip66-event-publisher'
+import { getPublicKey } from '../../../../src/utils/event'
+import { resetMonitorPrivateKeyCache } from '../../../../src/utils/monitor-identity'
import { RELAY_PROBE_SNAPSHOT_KEY, RelayProbeSnapshotStore } from '../../../../src/utils/relay-probe-snapshot'
import { SettingsStatic } from '../../../../src/utils/settings'
const INTEGRATION_RELAY_URL = 'ws://localhost:18808'
+const MONITOR_PRIVATE_KEY = '0000000000000000000000000000000000000000000000000000000000000001'
+const MONITOR_PUBKEY = getPublicKey(MONITOR_PRIVATE_KEY)
const SNAPSHOT_WAIT_MS = 15_000
const SNAPSHOT_POLL_MS = 100
const DISABLED_PROBE_WAIT_MS = 500
@@ -33,7 +41,9 @@ const defaultNip66Settings = {
let monitorWorker: RelayMonitorWorker | undefined
let snapshotStore: RelayProbeSnapshotStore | undefined
let cacheAdapter: RedisAdapter | undefined
+let eventPublisher: Nip66EventPublisher | undefined
let savedSettings: Settings | undefined
+let savedMonitorPrivateKey: string | undefined
const waitForSnapshot = async (): Promise => {
const deadline = Date.now() + SNAPSHOT_WAIT_MS
@@ -58,6 +68,8 @@ const startMonitorWorker = (): RelayMonitorWorker => {
createMonitorProcess(),
() => SettingsStatic._settings!,
snapshotStore!,
+ undefined,
+ eventPublisher,
)
worker.run()
@@ -67,9 +79,15 @@ const startMonitorWorker = (): RelayMonitorWorker => {
Before({ tags: '@nip-66' }, async function () {
savedSettings = SettingsStatic._settings
+ savedMonitorPrivateKey = process.env.MONITOR_PRIVATE_KEY
cacheAdapter = new RedisAdapter(getCacheClient())
snapshotStore = new RelayProbeSnapshotStore(cacheAdapter)
+ eventPublisher = new Nip66EventPublisher(
+ new EventRepository(getMasterDbClient(), getReadReplicaDbClient(), () => SettingsStatic._settings!),
+ cacheAdapter,
+ )
await cacheAdapter.deleteKey(RELAY_PROBE_SNAPSHOT_KEY)
+ await cacheAdapter.deleteKey(NIP66_MONITOR_BOOTSTRAPPED_KEY)
})
After({ tags: '@nip-66' }, async function () {
@@ -78,12 +96,26 @@ After({ tags: '@nip-66' }, async function () {
if (cacheAdapter) {
await cacheAdapter.deleteKey(RELAY_PROBE_SNAPSHOT_KEY)
+ await cacheAdapter.deleteKey(NIP66_MONITOR_BOOTSTRAPPED_KEY)
}
+ await getMasterDbClient()('events')
+ .where('event_pubkey', Buffer.from(MONITOR_PUBKEY, 'hex'))
+ .delete()
+
+ if (savedMonitorPrivateKey) {
+ process.env.MONITOR_PRIVATE_KEY = savedMonitorPrivateKey
+ } else {
+ delete process.env.MONITOR_PRIVATE_KEY
+ }
+ resetMonitorPrivateKeyCache()
+
SettingsStatic._settings = savedSettings
savedSettings = undefined
snapshotStore = undefined
cacheAdapter = undefined
+ eventPublisher = undefined
+ savedMonitorPrivateKey = undefined
})
Given('NIP-66 relay monitoring is disabled', function () {
@@ -100,6 +132,11 @@ Given('NIP-66 relay monitoring is enabled', function () {
)(SettingsStatic._settings) as Settings
})
+Given('the NIP-66 monitor private key is configured', function () {
+ process.env.MONITOR_PRIVATE_KEY = MONITOR_PRIVATE_KEY
+ resetMonitorPrivateKeyCache()
+})
+
Given('the probe target is {string}', function (target: string) {
SettingsStatic._settings = assocPath(['nip66', 'targets'], [target], SettingsStatic._settings) as Settings
})
@@ -153,3 +190,15 @@ Then('the snapshot uses the configured relay URL as its probe target', function
expect(snapshot.targets).to.deep.equal([INTEGRATION_RELAY_URL])
})
+
+Then('a kind 30166 event is stored for the monitor identity', async function () {
+ const tagRows = await getMasterDbClient()('event_tags')
+ .join('events', 'events.event_id', 'event_tags.event_id')
+ .where('events.event_kind', EventKinds.RELAY_DISCOVERY)
+ .where('events.event_pubkey', Buffer.from(MONITOR_PUBKEY, 'hex'))
+ .where('event_tags.tag_name', 'd')
+ .select('event_tags.tag_name', 'event_tags.tag_value')
+
+ expect(tagRows).to.have.length(1)
+ expect(tagRows[0].tag_value).to.equal(`${INTEGRATION_RELAY_URL}/`)
+})
diff --git a/test/unit/app/relay-monitor-worker.spec.ts b/test/unit/app/relay-monitor-worker.spec.ts
index 7fba5726..7d4df9b2 100644
--- a/test/unit/app/relay-monitor-worker.spec.ts
+++ b/test/unit/app/relay-monitor-worker.spec.ts
@@ -159,4 +159,25 @@ describe('RelayMonitorWorker', () => {
await new Promise((resolve) => setImmediate(resolve))
expect(fakeProcess.exit).to.have.been.calledOnceWithExactly(0)
})
+
+ it('publishes NIP-66 events after saving a probe snapshot', async () => {
+ const eventPublisher = {
+ publishAfterProbe: sandbox.stub().resolves(),
+ }
+
+ worker = new RelayMonitorWorker(
+ fakeProcess as unknown as NodeJS.Process,
+ settings,
+ snapshotStore,
+ probeRunner,
+ eventPublisher,
+ )
+
+ worker.run()
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(eventPublisher.publishAfterProbe).to.have.been.calledOnce
+ expect(eventPublisher.publishAfterProbe.firstCall.args[0].status).to.equal('ok')
+ })
})
diff --git a/test/unit/controllers/admin/get-network-health-controller.spec.ts b/test/unit/controllers/admin/get-network-health-controller.spec.ts
new file mode 100644
index 00000000..b345905f
--- /dev/null
+++ b/test/unit/controllers/admin/get-network-health-controller.spec.ts
@@ -0,0 +1,56 @@
+import { expect } from 'chai'
+import Sinon from 'sinon'
+
+import { IRelayProbeSnapshotStore, RelayProbeRunSnapshot } from '../../../../src/@types/relay-probe-snapshot'
+import { GetAdminNetworkHealthController } from '../../../../src/controllers/admin/get-network-health-controller'
+
+describe('GetAdminNetworkHealthController', () => {
+ let snapshotStore: Sinon.SinonStubbedInstance
+ let controller: GetAdminNetworkHealthController
+ let response: {
+ status: Sinon.SinonStub
+ setHeader: Sinon.SinonStub
+ send: Sinon.SinonStub
+ }
+
+ beforeEach(() => {
+ snapshotStore = {
+ saveLatest: Sinon.stub(),
+ getLatest: Sinon.stub(),
+ }
+
+ controller = new GetAdminNetworkHealthController(snapshotStore)
+
+ response = {
+ status: Sinon.stub().returnsThis(),
+ setHeader: Sinon.stub().returnsThis(),
+ send: Sinon.stub().returnsThis(),
+ }
+ })
+
+ it('returns the latest probe snapshot as JSON', async () => {
+ const snapshot: RelayProbeRunSnapshot = {
+ runAt: '2026-01-01T00:00:00.000Z',
+ targets: ['wss://relay.example.com'],
+ results: [],
+ status: 'ok',
+ }
+
+ snapshotStore.getLatest.resolves(snapshot)
+
+ await controller.handleRequest({} as any, response as any)
+
+ expect(snapshotStore.getLatest).to.have.been.calledOnce
+ expect(response.status).to.have.been.calledOnceWithExactly(200)
+ expect(response.setHeader).to.have.been.calledOnceWithExactly('content-type', 'application/json')
+ expect(response.send).to.have.been.calledOnceWithExactly({ snapshot })
+ })
+
+ it('returns null snapshot when no probe run has been stored yet', async () => {
+ snapshotStore.getLatest.resolves(null)
+
+ await controller.handleRequest({} as any, response as any)
+
+ expect(response.send).to.have.been.calledOnceWithExactly({ snapshot: null })
+ })
+})
diff --git a/test/unit/routes/admin.spec.ts b/test/unit/routes/admin.spec.ts
index 50ee6add..a06064cc 100644
--- a/test/unit/routes/admin.spec.ts
+++ b/test/unit/routes/admin.spec.ts
@@ -7,6 +7,7 @@ import { Tag } from '../../../src/@types/base'
import { EventKinds, EventTags } from '../../../src/constants/base'
import * as getAdminHealthControllerFactory from '../../../src/factories/controllers/get-admin-health-controller-factory'
import * as getAdminMetricsControllerFactory from '../../../src/factories/controllers/get-admin-metrics-controller-factory'
+import * as getAdminNetworkHealthControllerFactory from '../../../src/factories/controllers/get-admin-network-health-controller-factory'
import * as adminRateLimitMiddleware from '../../../src/handlers/request-handlers/admin-rate-limit-middleware'
import * as rateLimiterMiddleware from '../../../src/handlers/request-handlers/rate-limiter-middleware'
import * as settingsFactory from '../../../src/factories/settings-factory'
@@ -19,6 +20,7 @@ describe('admin router', () => {
const originalAdminPassword = process.env.ADMIN_PASSWORD
let createGetAdminHealthControllerStub: Sinon.SinonStub
let createGetAdminMetricsControllerStub: Sinon.SinonStub
+ let createGetAdminNetworkHealthControllerStub: Sinon.SinonStub
let createSettingsStub: Sinon.SinonStub
let rateLimiterMiddlewareStub: Sinon.SinonStub
let adminRateLimitMiddlewareStub: Sinon.SinonStub
@@ -67,6 +69,17 @@ describe('admin router', () => {
response.end()
},
} as any)
+ createGetAdminNetworkHealthControllerStub = Sinon.stub(
+ getAdminNetworkHealthControllerFactory,
+ 'createGetAdminNetworkHealthController',
+ ).returns({
+ handleRequest: async (_request: any, response: any) => {
+ response
+ .status(200)
+ .setHeader('content-type', 'application/json')
+ .send({ snapshot: null })
+ },
+ } as any)
createSettingsStub = Sinon.stub(settingsFactory, 'createSettings').returns(settings as any)
const passthrough = async (_request: any, _response: any, next: any) => {
next()
@@ -96,6 +109,7 @@ describe('admin router', () => {
const stopServer = async () => {
createGetAdminHealthControllerStub?.restore()
createGetAdminMetricsControllerStub?.restore()
+ createGetAdminNetworkHealthControllerStub?.restore()
createSettingsStub?.restore()
rateLimiterMiddlewareStub?.restore()
adminRateLimitMiddlewareStub?.restore()
@@ -175,11 +189,13 @@ describe('admin router', () => {
const sessionResponse = await axios.get(`${baseUrl}/session`, { validateStatus: () => true })
const healthResponse = await axios.get(`${baseUrl}/health`, { validateStatus: () => true })
const metricsResponse = await axios.get(`${baseUrl}/metrics`, { validateStatus: () => true })
+ const networkHealthResponse = await axios.get(`${baseUrl}/network-health`, { validateStatus: () => true })
expect(sessionResponse.status).to.equal(401)
expect(healthResponse.status).to.equal(401)
expect(metricsResponse.status).to.equal(401)
- expect(rateLimiterMiddlewareStub.callCount).to.equal(3)
+ expect(networkHealthResponse.status).to.equal(401)
+ expect(rateLimiterMiddlewareStub.callCount).to.equal(4)
})
it('authenticates a protected route with a signed NIP-98 event', async () => {
diff --git a/test/unit/services/nip66-event-publisher.spec.ts b/test/unit/services/nip66-event-publisher.spec.ts
new file mode 100644
index 00000000..c0bf0707
--- /dev/null
+++ b/test/unit/services/nip66-event-publisher.spec.ts
@@ -0,0 +1,127 @@
+import { createRequire } from 'node:module'
+import { fileURLToPath } from 'node:url'
+
+import chai from 'chai'
+import Sinon from 'sinon'
+import sinonChai from 'sinon-chai'
+
+chai.use(sinonChai)
+
+const { expect } = chai
+const require = createRequire(fileURLToPath(import.meta.url))
+const eventUtils = require('../../../src/utils/event') as typeof import('../../../src/utils/event')
+const monitorIdentity = require('../../../src/utils/monitor-identity') as typeof import('../../../src/utils/monitor-identity')
+const {
+ NIP66_MONITOR_BOOTSTRAP_TTL_SECONDS,
+ NIP66_MONITOR_BOOTSTRAPPED_KEY,
+ Nip66EventPublisher,
+} = require('../../../src/services/nip66-event-publisher') as typeof import('../../../src/services/nip66-event-publisher')
+
+const monitorPrivkey = '0000000000000000000000000000000000000000000000000000000000000001'
+
+describe('Nip66EventPublisher', () => {
+ let sandbox: Sinon.SinonSandbox
+ let eventRepository: { upsert: Sinon.SinonStub }
+ let cache: { getKey: Sinon.SinonStub; setKey: Sinon.SinonStub }
+ let publisher: InstanceType
+
+ const settings = {
+ info: { relay_url: 'wss://relay.example.com', name: 'relay.example.com' },
+ nip66: {
+ enabled: true,
+ probeIntervalSeconds: 3600,
+ targets: ['wss://external.example.com'],
+ timeouts: { dnsMs: 1, tlsMs: 1, wsRttMs: 1, nip11Ms: 1 },
+ dnsCacheTtlSeconds: 300,
+ },
+ }
+
+ const snapshot = {
+ runAt: '2026-01-01T00:00:00.000Z',
+ targets: ['wss://external.example.com'],
+ status: 'ok',
+ results: [
+ {
+ target: {
+ relayUrl: 'wss://external.example.com',
+ hostname: 'external.example.com',
+ networkType: 'clearnet',
+ httpOrigin: 'https://external.example.com',
+ nip11Url: 'https://external.example.com/',
+ wsUrl: 'wss://external.example.com',
+ },
+ checkedAt: '2026-01-01T00:00:00.000Z',
+ dns: { status: 'ok', durationMs: 1 },
+ tls: { status: 'ok', durationMs: 1 },
+ wsRtt: { status: 'ok', durationMs: 1, data: { rttOpenMs: 100, address: 'wss://external.example.com' } },
+ nip11: { status: 'ok', durationMs: 1, data: { statusCode: 200 } },
+ },
+ ],
+ }
+
+ let monitorPubkey: string
+
+ beforeEach(() => {
+ sandbox = Sinon.createSandbox()
+ monitorPubkey = eventUtils.getPublicKey(monitorPrivkey)
+ eventRepository = { upsert: sandbox.stub().resolves(1) }
+ cache = {
+ getKey: sandbox.stub().resolves(null),
+ setKey: sandbox.stub().resolves(true),
+ }
+ publisher = new Nip66EventPublisher(eventRepository, cache)
+
+ sandbox.stub(monitorIdentity, 'getMonitorPrivateKey').returns(monitorPrivkey)
+ sandbox.stub(eventUtils, 'getPublicKey').returns(monitorPubkey)
+ sandbox.stub(eventUtils, 'identifyEvent').callsFake(async (event) => ({ ...event, id: 'event-id' }))
+ sandbox.stub(eventUtils, 'signEvent').returns(async (event: any) => ({ ...event, sig: 'sig' }))
+ sandbox.stub(eventUtils, 'broadcastEvent').resolves({} as any)
+ })
+
+ afterEach(() => {
+ sandbox.restore()
+ })
+
+ it('skips publish when MONITOR_PRIVATE_KEY is missing', async () => {
+ ;(monitorIdentity.getMonitorPrivateKey as Sinon.SinonStub).returns(undefined)
+
+ await publisher.publishAfterProbe(snapshot as any, settings as any)
+
+ expect(eventRepository.upsert).to.not.have.been.called
+ })
+
+ it('bootstraps once and broadcasts newly persisted events', async () => {
+ await publisher.publishAfterProbe(snapshot as any, settings as any)
+
+ expect(cache.setKey).to.have.been.calledOnceWithExactly(
+ NIP66_MONITOR_BOOTSTRAPPED_KEY,
+ monitorPubkey,
+ NIP66_MONITOR_BOOTSTRAP_TTL_SECONDS,
+ )
+ expect(eventRepository.upsert).to.have.callCount(4)
+ expect(eventUtils.broadcastEvent).to.have.callCount(4)
+
+ const relayListEvent = (eventUtils.identifyEvent as Sinon.SinonStub).getCall(1).args[0]
+ expect(relayListEvent.kind).to.equal(10002)
+ expect(relayListEvent.tags[0]).to.deep.equal(['r', 'wss://relay.example.com', 'read'])
+ })
+
+ it('does not rebootstrap when the bootstrap flag is already set', async () => {
+ cache.getKey.resolves(monitorPubkey)
+
+ await publisher.publishAfterProbe(snapshot as any, settings as any)
+
+ expect(cache.setKey).to.not.have.been.called
+ expect(eventRepository.upsert).to.have.callCount(2)
+ })
+
+ it('does not broadcast duplicate upserts', async () => {
+ cache.getKey.resolves(monitorPubkey)
+ eventRepository.upsert.resolves(0)
+
+ await publisher.publishAfterProbe(snapshot as any, settings as any)
+
+ expect(eventRepository.upsert).to.have.callCount(2)
+ expect(eventUtils.broadcastEvent).to.not.have.been.called
+ })
+})
diff --git a/test/unit/utils/nip66-events.spec.ts b/test/unit/utils/nip66-events.spec.ts
new file mode 100644
index 00000000..eb77867b
--- /dev/null
+++ b/test/unit/utils/nip66-events.spec.ts
@@ -0,0 +1,91 @@
+import chai from 'chai'
+
+import { EventKinds, EventTags } from '../../../src/constants/base'
+import { StoredProbeResult } from '../../../src/@types/relay-probe-snapshot'
+import { Settings } from '../../../src/@types/settings'
+import {
+ buildMonitorAnnouncementEvent,
+ buildMonitorProfileEvent,
+ buildMonitorRelayListEvent,
+ buildRelayDiscoveryEvent,
+ normalizeRelayUrlForDTag,
+} from '../../../src/utils/nip66-events'
+import { MIN_PROBE_INTERVAL_SECONDS } from '../../../src/utils/nip66-schedule'
+
+const { expect } = chai
+
+const monitorPubkey = 'a'.repeat(64)
+
+const storedProbeResult = (relayUrl = 'wss://Relay.Example.com:443/'): StoredProbeResult =>
+ ({
+ target: {
+ relayUrl,
+ hostname: 'relay.example.com',
+ networkType: 'clearnet',
+ httpOrigin: 'https://relay.example.com',
+ nip11Url: 'https://relay.example.com/.well-known/nostr.json',
+ wsUrl: 'wss://relay.example.com',
+ },
+ checkedAt: '2026-01-01T00:00:00.000Z',
+ dns: { status: 'ok', durationMs: 1 },
+ tls: { status: 'ok', durationMs: 1 },
+ wsRtt: { status: 'ok', durationMs: 12, data: { rttOpenMs: 234, address: '127.0.0.1:443' } },
+ nip11: { status: 'ok', durationMs: 1 },
+ }) as StoredProbeResult
+
+describe('nip66-events', () => {
+ 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/')
+ })
+
+ it('builds kind 30166 relay discovery events from probe results', () => {
+ const event = buildRelayDiscoveryEvent(storedProbeResult(), monitorPubkey, 1_700_000_000)
+
+ expect(event.kind).to.equal(EventKinds.RELAY_DISCOVERY)
+ expect(event.pubkey).to.equal(monitorPubkey)
+ 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'])
+ })
+
+ it('builds kind 10166 monitor announcement events', () => {
+ const settings = {
+ info: { relay_url: 'wss://relay.example.com' },
+ nip66: {
+ enabled: true,
+ probeIntervalSeconds: 10,
+ targets: [],
+ timeouts: {
+ dnsMs: 1000,
+ tlsMs: 2000,
+ wsRttMs: 3000,
+ nip11Ms: 4000,
+ },
+ dnsCacheTtlSeconds: 300,
+ },
+ } as Settings
+
+ const event = buildMonitorAnnouncementEvent(settings, monitorPubkey, 1_700_000_000)
+
+ 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', 'nip11', '4000'])
+ expect(event.tags).to.deep.include(['c', 'dns'])
+ })
+
+ it('builds bootstrap profile and relay list events', () => {
+ const profile = buildMonitorProfileEvent(monitorPubkey, 1)
+ 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(relayList.kind).to.equal(EventKinds.RELAY_LIST)
+ expect(relayList.tags).to.deep.equal([
+ [EventTags.Relay, 'wss://relay.example.com', 'read'],
+ [EventTags.Relay, 'wss://relay.example.com', 'write'],
+ ])
+ })
+})
diff --git a/test/unit/utils/nip66-schedule.spec.ts b/test/unit/utils/nip66-schedule.spec.ts
new file mode 100644
index 00000000..522a0775
--- /dev/null
+++ b/test/unit/utils/nip66-schedule.spec.ts
@@ -0,0 +1,25 @@
+import { expect } from 'chai'
+
+import { Settings } from '../../../src/@types/settings'
+import {
+ getEffectiveProbeIntervalSeconds,
+ getProbeIntervalMs,
+ MIN_PROBE_INTERVAL_SECONDS,
+} from '../../../src/utils/nip66-schedule'
+
+describe('nip66-schedule', () => {
+ const settings = (probeIntervalSeconds?: number): Settings =>
+ ({
+ nip66: probeIntervalSeconds === undefined ? undefined : { enabled: true, probeIntervalSeconds, targets: [] },
+ }) as Settings
+
+ it('clamps probe intervals below the worker minimum', () => {
+ expect(getEffectiveProbeIntervalSeconds(settings(10))).to.equal(MIN_PROBE_INTERVAL_SECONDS)
+ expect(getProbeIntervalMs(settings(10))).to.equal(MIN_PROBE_INTERVAL_SECONDS * 1000)
+ })
+
+ it('uses configured probe intervals at or above the minimum', () => {
+ expect(getEffectiveProbeIntervalSeconds(settings(120))).to.equal(120)
+ expect(getProbeIntervalMs(settings(120))).to.equal(120_000)
+ })
+})