Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
36 changes: 26 additions & 10 deletions resources/admin/assets/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -536,18 +536,11 @@
})
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}`
})

if (
result.nip11?.status === 'ok' &&
Array.isArray(result.nip11?.data?.supportedNips) &&
!result.nip11.data.supportedNips.includes(66)
) {
const mismatches = Array.isArray(result.mismatches) ? result.mismatches : []
if (mismatches.some((entry) => entry?.severity === 'warning')) {
nip11.className = 'status-degraded'
}
Comment thread
Ferryx349 marked this conversation as resolved.
Outdated

Expand All @@ -574,6 +567,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
22 changes: 20 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,31 @@ import { Request, Response } from 'express'

import { IController } from '../../@types/controllers'
import { IRelayProbeSnapshotStore } from '../../@types/relay-probe-snapshot'
import { Settings } from '../../@types/settings'
import { enrichSnapshotWithMismatches } from '../../utils/network-health-mismatches'
import { resolvePublicProbeTargetKeys } from '../../utils/relay-probe-targets'
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, {
configuredRelayUrl: settings.info?.relay_url,
publicTargetKeys: resolvePublicProbeTargetKeys(settings),
})
Comment thread
Ferryx349 marked this conversation as resolved.
Outdated

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

export type NetworkHealthMismatchSeverity = 'warning' | 'info'

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

export interface NetworkHealthMismatchContext {
configuredRelayUrl?: string
/** Normalized keys from resolvePublicProbeTargetKeys (publish/probe set). */
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 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

if (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)
const probedKey = normalizeRelayUrlOrUndefined(result.target.relayUrl)
const singlePublicTarget =
context.publicTargetKeys?.size === 1 && configuredKey && context.publicTargetKeys.has(configuredKey)

if (singlePublicTarget && configuredKey && probedKey && probedKey !== configuredKey) {
Comment thread
Ferryx349 marked this conversation as resolved.
Outdated
mismatches.push({
code: 'public-url',
severity: 'warning',
message: `Probe target ${result.target.relayUrl} does not match info.relay_url (${context.configuredRelayUrl}).`,
})
}

if (nip11 && ws) {
const advertisesAuth = limitation?.authRequired === true
const observedAuth = ws.nip42AuthRequired === true || ws.nip42ChallengeObserved === true

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

if (!advertisesAuth && observedAuth) {
mismatches.push({
code: 'nip42-not-advertised',
severity: 'warning',
message: 'WebSocket probe observed NIP-42 but NIP-11 does not advertise auth-required.',
Comment thread
Ferryx349 marked this conversation as resolved.
Outdated
})
}
}

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