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

+
+
+
+
+

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