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/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 @@
Throughput
diff --git a/src/app/relay-monitor-worker.ts b/src/app/relay-monitor-worker.ts
index 0e91b4a7..48097f69 100644
--- a/src/app/relay-monitor-worker.ts
+++ b/src/app/relay-monitor-worker.ts
@@ -4,9 +4,9 @@ import { Settings } from '../@types/settings'
import { createLogger } from '../factories/logger-factory'
import { INip66EventPublisher } from '../services/nip66-event-publisher'
import { shutdownMetricsTelemetry } from '../telemetry/metrics'
-import { getEffectiveProbeIntervalSeconds } from '../utils/nip66-events'
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'
@@ -23,9 +23,7 @@ export const buildProbeOptions = (settings: Settings): ProbeOptions => {
}
}
-export const getProbeIntervalMs = (settings: Settings): number => {
- return getEffectiveProbeIntervalSeconds(settings) * 1000
-}
+export { getProbeIntervalMs } from '../utils/nip66-schedule'
export class RelayMonitorWorker implements IRunnable {
private interval: NodeJS.Timeout | undefined
diff --git a/src/services/nip66-event-publisher.ts b/src/services/nip66-event-publisher.ts
index 4ab07115..337e04a7 100644
--- a/src/services/nip66-event-publisher.ts
+++ b/src/services/nip66-event-publisher.ts
@@ -1,11 +1,11 @@
import { ICacheAdapter } from '../@types/adapters'
-import { ParameterizedReplaceableEvent, UnidentifiedEvent } from '../@types/event'
+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 { getPublicKey, identifyEvent, isParameterizedReplaceableEvent, signEvent } from '../utils/event'
+import { broadcastEvent, getPublicKey, identifyEvent, isParameterizedReplaceableEvent, signEvent } from '../utils/event'
import { getMonitorPrivateKey } from '../utils/monitor-identity'
import {
buildMonitorAnnouncementEvent,
@@ -18,6 +18,7 @@ import { filterValidProbeTargets } 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
@@ -63,7 +64,7 @@ export class Nip66EventPublisher implements INip66EventPublisher {
return
}
- const { valid } = filterValidProbeTargets([settings.info.relay_url?.trim() ?? ''])
+ const { valid } = filterValidProbeTargets([settings.info?.relay_url?.trim() ?? ''])
const [relayUrl] = valid
if (!relayUrl) {
@@ -74,12 +75,13 @@ export class Nip66EventPublisher implements INip66EventPublisher {
await this.persistSignedEvent(buildMonitorProfileEvent(monitorPubkey, createdAt), privkey)
await this.persistSignedEvent(buildMonitorRelayListEvent(relayUrl, monitorPubkey, createdAt), privkey)
- await this.cache.setKey(NIP66_MONITOR_BOOTSTRAPPED_KEY, monitorPubkey)
+ 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) ?? [
@@ -87,14 +89,16 @@ export class Nip66EventPublisher implements INip66EventPublisher {
'',
]
- await this.eventRepository.upsert({
+ count = await this.eventRepository.upsert({
...signed,
[EventDeduplicationMetadataKey]: deduplication ? [deduplication] : [''],
} as ParameterizedReplaceableEvent)
-
- return
+ } else {
+ count = await this.eventRepository.upsert(signed as Event)
}
- await this.eventRepository.upsert(signed)
+ if (count) {
+ await broadcastEvent(signed)
+ }
}
}
diff --git a/src/utils/nip66-events.ts b/src/utils/nip66-events.ts
index a2a1559c..da9ca75e 100644
--- a/src/utils/nip66-events.ts
+++ b/src/utils/nip66-events.ts
@@ -3,15 +3,7 @@ import { Tag } from '../@types/base'
import { StoredProbeResult } from '../@types/relay-probe-snapshot'
import { Settings } from '../@types/settings'
import { EventKinds, EventTags } from '../constants/base'
-
-const DEFAULT_PROBE_INTERVAL_SECONDS = 3600
-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)
-}
+import { getEffectiveProbeIntervalSeconds } from './nip66-schedule'
const appendDnsProbeTags = (tags: Tag[], dns: StoredProbeResult['dns']): void => {
if (dns.status === 'skipped') {
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/unit/services/nip66-event-publisher.spec.ts b/test/unit/services/nip66-event-publisher.spec.ts
index 2f59b7b4..e7a8f22d 100644
--- a/test/unit/services/nip66-event-publisher.spec.ts
+++ b/test/unit/services/nip66-event-publisher.spec.ts
@@ -1,95 +1,149 @@
+import { createRequire } from 'node:module'
+import { fileURLToPath } from 'node:url'
+
import chai from 'chai'
import Sinon from 'sinon'
import sinonChai from 'sinon-chai'
-import { EventKinds } from '../../../src/constants/base'
-import { Nip66EventPublisher, NIP66_MONITOR_BOOTSTRAPPED_KEY } from '../../../src/services/nip66-event-publisher'
-import * as eventUtils from '../../../src/utils/event'
-import { resetMonitorPrivateKeyCache } from '../../../src/utils/monitor-identity'
-
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 PRIVKEY = 'f'.repeat(64)
+const monitorPrivkey = '0000000000000000000000000000000000000000000000000000000000000001'
describe('Nip66EventPublisher', () => {
let sandbox: Sinon.SinonSandbox
let eventRepository: { upsert: Sinon.SinonStub }
let cache: { getKey: Sinon.SinonStub; setKey: Sinon.SinonStub }
- let publisher: Nip66EventPublisher
+ let publisher: InstanceType
const settings = {
- info: { relay_url: 'wss://relay.example.com' },
- nip66: { enabled: true, probeIntervalSeconds: 3600, targets: ['wss://other.example.com'], timeouts: {} },
- } as any
+ 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: new Date().toISOString(),
- targets: ['wss://relay.example.com'],
+ runAt: '2026-01-01T00:00:00.000Z',
+ targets: ['wss://external.example.com'],
status: 'ok',
- results: [],
- } as any
+ 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()
- resetMonitorPrivateKeyCache()
- process.env.MONITOR_PRIVATE_KEY = PRIVKEY
-
- eventRepository = { upsert: sandbox.stub().resolves() }
+ monitorPubkey = eventUtils.getPublicKey(monitorPrivkey)
+ eventRepository = { upsert: sandbox.stub().resolves(1) }
cache = {
- getKey: sandbox.stub().resolves(undefined),
- setKey: sandbox.stub().resolves(),
+ getKey: sandbox.stub().resolves(null),
+ setKey: sandbox.stub().resolves(true),
}
+ publisher = new Nip66EventPublisher(eventRepository, cache)
- sandbox.stub(eventUtils, 'getPublicKey').returns('b'.repeat(64))
- sandbox.stub(eventUtils, 'identifyEvent').callsFake(async (event: any) => ({ ...event, id: 'id'.repeat(16) }))
- sandbox.stub(eventUtils, 'signEvent').returns(async (event: any) => ({ ...event, sig: 'sig'.repeat(32) }))
- sandbox.stub(eventUtils, 'isParameterizedReplaceableEvent').returns(false)
-
- publisher = new Nip66EventPublisher(eventRepository as any, cache as any)
+ 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(() => {
- delete process.env.MONITOR_PRIVATE_KEY
- resetMonitorPrivateKeyCache()
sandbox.restore()
})
- it('bootstraps with the public relay URL from settings', async () => {
- await publisher.publishAfterProbe(snapshot, settings)
+ it('skips publish when MONITOR_PRIVATE_KEY is missing', async () => {
+ ;(monitorIdentity.getMonitorPrivateKey as Sinon.SinonStub).returns(undefined)
- const relayListUpsert = eventRepository.upsert.getCalls().find((call) => call.args[0].kind === EventKinds.RELAY_LIST)
+ await publisher.publishAfterProbe(snapshot as any, settings as any)
- expect(relayListUpsert).to.exist
- expect(relayListUpsert!.args[0].tags).to.deep.include(['r', 'wss://relay.example.com', 'read'])
- expect(cache.setKey).to.have.been.calledWith(NIP66_MONITOR_BOOTSTRAPPED_KEY, 'b'.repeat(64))
+ expect(eventRepository.upsert).to.not.have.been.called
})
- it('skips bootstrap when the configured relay URL is invalid', async () => {
- const invalidSettings = { ...settings, info: { relay_url: 'not a relay url' } } as any
+ it('bootstraps once and broadcasts newly persisted events', async () => {
+ await publisher.publishAfterProbe(snapshot as any, settings as any)
- await publisher.publishAfterProbe(snapshot, invalidSettings)
+ 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 bootstrapKinds = [EventKinds.SET_METADATA, EventKinds.RELAY_LIST]
- const bootstrapUpserts = eventRepository.upsert
- .getCalls()
- .filter((call) => bootstrapKinds.includes(call.args[0].kind))
+ 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(bootstrapUpserts).to.be.empty
expect(cache.setKey).to.not.have.been.called
+ expect(eventRepository.upsert).to.have.callCount(2)
})
- it('re-bootstraps when the monitor pubkey changes', async () => {
+ it('rebootstraps when the cached monitor pubkey no longer matches', async () => {
cache.getKey.resolves('a'.repeat(64))
- await publisher.publishAfterProbe(snapshot, settings)
+ 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)
+ })
+
+ it('skips bootstrap when the configured relay URL is invalid', async () => {
+ const invalidSettings = { ...settings, info: { ...settings.info, relay_url: 'not a relay url' } }
+
+ await publisher.publishAfterProbe(snapshot as any, invalidSettings 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)
- const profileUpserts = eventRepository.upsert
- .getCalls()
- .filter((call) => call.args[0].kind === EventKinds.SET_METADATA)
+ await publisher.publishAfterProbe(snapshot as any, settings as any)
- expect(profileUpserts).to.have.length(1)
- expect(cache.setKey).to.have.been.calledWith(NIP66_MONITOR_BOOTSTRAPPED_KEY, 'b'.repeat(64))
+ 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
index ebd5c9c7..67b0de0d 100644
--- a/test/unit/utils/nip66-events.spec.ts
+++ b/test/unit/utils/nip66-events.spec.ts
@@ -10,6 +10,7 @@ import {
buildRelayDiscoveryEvent,
normalizeRelayUrlForDTag,
} from '../../../src/utils/nip66-events'
+import { MIN_PROBE_INTERVAL_SECONDS } from '../../../src/utils/nip66-schedule'
const { expect } = chai
@@ -55,7 +56,7 @@ describe('nip66-events', () => {
info: { relay_url: 'wss://relay.example.com' },
nip66: {
enabled: true,
- probeIntervalSeconds: 3600,
+ probeIntervalSeconds: 10,
targets: [],
timeouts: {
dnsMs: 1000,
@@ -70,15 +71,7 @@ describe('nip66-events', () => {
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', '3600'])
-
- const clampedSettings = {
- ...settings,
- nip66: { ...settings.nip66!, probeIntervalSeconds: 10 },
- } as Settings
-
- const clamped = buildMonitorAnnouncementEvent(clampedSettings, monitorPubkey, 1_700_000_000)
- expect(clamped.tags).to.deep.include(['frequency', '60'])
+ 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'])
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)
+ })
+})