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 @@

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

+
+
+
+
+

TGT Targets

+

0

+
+
+
+
+

TS Last probe run

+

+
+
+
+
+
+

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) + }) +})