Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/network-health-mismatch-warnings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"nostream": minor
---

feat(admin): add NIP-11 vs probe mismatch warnings on Network Health

Closes #798
34 changes: 34 additions & 0 deletions resources/admin/assets/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,40 @@
margin-bottom: 0;
}

.network-health-mismatches {
border-top: 1px solid var(--panel-border);
margin-top: 0.75rem;
padding-top: 0.65rem;
}

.network-health-mismatch-heading {
color: var(--label);
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}

.network-health-mismatch-list {
display: grid;
gap: 0.35rem;
list-style: none;
padding-left: 0;
}

.network-health-mismatch {
font-size: 0.78rem;
line-height: 1.35;
}

.network-health-mismatch-warning {
color: var(--status-degraded, #c9a227);
}

.network-health-mismatch-info {
color: var(--label);
}

.notifications-target-card .notifications-target-type {
max-width: 10rem;
}
Expand Down
33 changes: 26 additions & 7 deletions resources/admin/assets/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -536,17 +536,13 @@
})
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}`
return `HTTP ${data.statusCode}${name}`
})

const mismatches = Array.isArray(result.mismatches) ? result.mismatches : []
if (
result.nip11?.status === 'ok' &&
Array.isArray(result.nip11?.data?.supportedNips) &&
!result.nip11.data.supportedNips.includes(66)
mismatches.some((entry) => entry?.severity === 'warning')
) {
nip11.className = 'status-degraded'
}
Expand Down Expand Up @@ -574,6 +570,29 @@
})

card.appendChild(checks)

if (mismatches.length > 0) {
const mismatchBlock = document.createElement('div')
mismatchBlock.className = 'network-health-mismatches'

const heading = document.createElement('p')
heading.className = 'network-health-mismatch-heading mb-1'
heading.textContent = 'Advertised vs observed'
mismatchBlock.appendChild(heading)

const list = document.createElement('ul')
list.className = 'network-health-mismatch-list mb-0'

mismatches.forEach((entry) => {
const item = document.createElement('li')
item.className = `network-health-mismatch network-health-mismatch-${entry.severity ?? 'warning'}`
item.textContent = entry.message ?? entry.code ?? 'Mismatch'
list.appendChild(item)
})

mismatchBlock.appendChild(list)
card.appendChild(mismatchBlock)
}
networkHealthResults.appendChild(card)
})
}
Expand Down
7 changes: 7 additions & 0 deletions src/@types/relay-probe-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,18 @@ export interface StoredProbeResult {
nip11: ProbeCheckResult<Nip11Result>
}

/** Settings captured when the probe run executed (for mismatch checks on cached snapshots). */
export interface RelayProbeRunContext {
configuredRelayUrl?: string
publicTargetKeys: string[]
}

export interface RelayProbeRunSnapshot {
runAt: string
targets: string[]
results: StoredProbeResult[]
status: RelayProbeRunStatus
probeContext?: RelayProbeRunContext
}

export interface IRelayProbeSnapshotStore {
Expand Down
7 changes: 7 additions & 0 deletions src/app/relay-monitor-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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 { resolvePublicProbeTargetKeys } from '../utils/relay-probe-targets'
import { getEffectiveProbeIntervalSeconds, getProbeIntervalMs } from '../utils/nip66-schedule'
import { getMonitorPrivateKey } from '../utils/monitor-identity'
import { runProbe } from '../utils/relay-probe'
Expand Down Expand Up @@ -119,11 +120,17 @@ export class RelayMonitorWorker implements IRunnable {
return
}

const publicTargetKeys = resolvePublicProbeTargetKeys(currentSettings)

const snapshot: RelayProbeRunSnapshot = {
runAt: new Date().toISOString(),
targets: valid,
results: serializeProbeResults(results),
status: deriveRelayProbeRunStatus(results),
probeContext: {
configuredRelayUrl: currentSettings.info?.relay_url,
publicTargetKeys: [...publicTargetKeys],
},
}

const expirySeconds = getEffectiveProbeIntervalSeconds(currentSettings) * 2
Expand Down
24 changes: 22 additions & 2 deletions src/controllers/admin/get-network-health-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,33 @@ import { Request, Response } from 'express'

import { IController } from '../../@types/controllers'
import { IRelayProbeSnapshotStore } from '../../@types/relay-probe-snapshot'
import { Settings } from '../../@types/settings'
import {
buildNetworkHealthMismatchContext,
enrichSnapshotWithMismatches,
} from '../../utils/network-health-mismatches'
import { loadMergedSettings } from '../../utils/settings-config'

export class GetAdminNetworkHealthController implements IController {
public constructor(private readonly snapshotStore: IRelayProbeSnapshotStore) {}
public constructor(
private readonly snapshotStore: IRelayProbeSnapshotStore,
private readonly getSettings: () => Settings = loadMergedSettings,
) {}

public async handleRequest(_request: Request, response: Response): Promise<void> {
const snapshot = await this.snapshotStore.getLatest()

response.status(200).setHeader('content-type', 'application/json').send({ snapshot })
if (!snapshot) {
response.status(200).setHeader('content-type', 'application/json').send({ snapshot: null })
return
}

const settings = this.getSettings()
const enriched = enrichSnapshotWithMismatches(
snapshot,
buildNetworkHealthMismatchContext(snapshot, settings),
)

response.status(200).setHeader('content-type', 'application/json').send({ snapshot: enriched })
}
}
138 changes: 138 additions & 0 deletions src/utils/network-health-mismatches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { RelayProbeRunSnapshot, StoredProbeResult } from '../@types/relay-probe-snapshot'
import { Settings } from '../@types/settings'
import { normalizeRelayUrlForDTag } from './nip66-events'
import { resolvePublicProbeTargetKeys } from './relay-probe-targets'

export type NetworkHealthMismatchSeverity = 'warning' | 'info'

export interface NetworkHealthMismatch {
code: string
severity: NetworkHealthMismatchSeverity
message: string
}

export interface NetworkHealthMismatchContext {
configuredRelayUrl?: string
/** Normalized keys from resolvePublicProbeTargetKeys at probe time (or current settings). */
publicTargetKeys?: Set<string>
}

const normalizeRelayUrlOrUndefined = (relayUrl: string | undefined): string | undefined => {
if (!relayUrl?.trim()) {
return undefined
}

try {
return normalizeRelayUrlForDTag(relayUrl.trim())
} catch {
return undefined
}
}

export const buildNetworkHealthMismatchContext = (
snapshot: RelayProbeRunSnapshot,
settings: Settings,
): NetworkHealthMismatchContext => {
if (snapshot.probeContext) {
return {
configuredRelayUrl: snapshot.probeContext.configuredRelayUrl,
publicTargetKeys: new Set(snapshot.probeContext.publicTargetKeys),
}
}

return {
configuredRelayUrl: settings.info?.relay_url,
publicTargetKeys: resolvePublicProbeTargetKeys(settings),
}
}

const isPublicProbeTarget = (probedKey: string | undefined, context: NetworkHealthMismatchContext): boolean =>
Boolean(probedKey && context.publicTargetKeys?.has(probedKey))

export const collectNetworkHealthMismatches = (
result: StoredProbeResult,
context: NetworkHealthMismatchContext = {},
): NetworkHealthMismatch[] => {
const mismatches: NetworkHealthMismatch[] = []
const nip11 = result.nip11.status === 'ok' ? result.nip11.data : undefined
const ws = result.wsRtt.status === 'ok' ? result.wsRtt.data : undefined
const limitation = nip11?.limitation
const probedKey = normalizeRelayUrlOrUndefined(result.target.relayUrl)
const isPublicTarget = isPublicProbeTarget(probedKey, context)

if (
isPublicTarget &&
nip11 &&
Array.isArray(nip11.supportedNips) &&
!nip11.supportedNips.includes(66)
) {
mismatches.push({
code: 'nip66-not-advertised',
severity: 'warning',
message: 'NIP-11 supported_nips does not include 66 while this relay publishes NIP-66 monitor data.',
})
}

const configuredKey = normalizeRelayUrlOrUndefined(context.configuredRelayUrl)

if (isPublicTarget && configuredKey && probedKey && probedKey !== configuredKey) {
mismatches.push({
code: 'public-url',
severity: 'warning',
message: `Probe target ${result.target.relayUrl} does not match info.relay_url (${context.configuredRelayUrl}).`,
})
}

if (nip11 && ws && isPublicTarget) {
const advertisesConnectionAuth = limitation?.authRequired === true
const advertisesWriteAuth = limitation?.restrictedWrites === true
const observedAuthRequired = ws.nip42AuthRequired === true

if (advertisesConnectionAuth && !observedAuthRequired && ws.nip42ChallengeObserved !== true) {
mismatches.push({
code: 'nip42-not-observed',
severity: 'warning',
message: 'NIP-11 advertises auth-required but the WebSocket probe did not observe NIP-42.',
})
}

if (!advertisesConnectionAuth && !advertisesWriteAuth && observedAuthRequired) {
mismatches.push({
code: 'nip42-not-advertised',
severity: 'warning',
message: 'WebSocket probe observed NIP-42 auth-required but NIP-11 does not advertise auth or restricted writes.',
})
}
}

if (nip11 && typeof limitation?.minPowDifficulty === 'number' && limitation.minPowDifficulty > 0) {
mismatches.push({
code: 'pow-advertised',
severity: 'info',
message: `NIP-11 advertises min PoW difficulty ${limitation.minPowDifficulty}; probes do not validate PoW on publish.`,
})
}

if (nip11 && limitation?.paymentRequired === true) {
mismatches.push({
code: 'payment-advertised',
severity: 'info',
message: 'NIP-11 advertises payment-required; confirm fee/admission settings match operator expectations.',
})
}

return mismatches
}

export const enrichSnapshotWithMismatches = <T extends { results: StoredProbeResult[] }>(
snapshot: T,
context: NetworkHealthMismatchContext,
): T & { results: Array<StoredProbeResult & { mismatches: NetworkHealthMismatch[] }> } => {
return {
...snapshot,
results: snapshot.results.map((result) => ({
...result,
mismatches: collectNetworkHealthMismatches(result, context),
})),
}
}
36 changes: 32 additions & 4 deletions test/unit/controllers/admin/get-network-health-controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import chai from 'chai'
import Sinon from 'sinon'
import sinonChai from 'sinon-chai'

import { Settings } from '../../../../src/@types/settings'
import { IRelayProbeSnapshotStore, RelayProbeRunSnapshot } from '../../../../src/@types/relay-probe-snapshot'
import { GetAdminNetworkHealthController } from '../../../../src/controllers/admin/get-network-health-controller'

Expand All @@ -18,13 +19,18 @@ describe('GetAdminNetworkHealthController', () => {
send: Sinon.SinonStub
}

const settings = {
info: { relay_url: 'wss://relay.example.com/' },
nip66: { enabled: true, targets: [] },
} as Settings

beforeEach(() => {
snapshotStore = {
saveLatest: Sinon.stub(),
getLatest: Sinon.stub(),
}

controller = new GetAdminNetworkHealthController(snapshotStore)
controller = new GetAdminNetworkHealthController(snapshotStore, () => settings)

response = {
status: Sinon.stub().returnsThis(),
Expand All @@ -33,11 +39,31 @@ describe('GetAdminNetworkHealthController', () => {
}
})

it('returns the latest probe snapshot as JSON', async () => {
it('returns the latest probe snapshot with mismatch annotations', async () => {
const snapshot: RelayProbeRunSnapshot = {
runAt: '2026-01-01T00:00:00.000Z',
targets: ['wss://relay.example.com'],
results: [],
results: [
{
target: {
relayUrl: 'wss://relay.example.com/',
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: 1, data: { rttOpenMs: 1, address: 'wss://relay.example.com/' } },
nip11: {
status: 'ok',
durationMs: 1,
data: { statusCode: 200, supportedNips: [1, 11] },
},
},
],
status: 'ok',
}

Expand All @@ -48,7 +74,9 @@ describe('GetAdminNetworkHealthController', () => {
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 })
const payload = response.send.firstCall.args[0] as { snapshot: { results: Array<{ mismatches: unknown[] }> } }
expect(payload.snapshot.results[0].mismatches.some((entry: { code: string }) => entry.code === 'nip66-not-advertised')).to
.equal(true)
})

it('returns null snapshot when no probe run has been stored yet', async () => {
Expand Down
Loading
Loading