From c43c2136e341f45b5d3cbd02ed4e341f4d0b3323 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Tue, 6 Oct 2026 10:13:37 +0530 Subject: [PATCH 1/2] feat(admin): NIP-11 vs probe mismatch warnings on Network Health Annotate network-health snapshots with advertised vs observed mismatches and surface them in the admin dashboard. Closes #798. --- .../network-health-mismatch-warnings.md | 7 ++ resources/admin/assets/dashboard.css | 34 ++++++ resources/admin/assets/dashboard.js | 36 ++++-- .../admin/get-network-health-controller.ts | 22 +++- src/utils/network-health-mismatches.ts | 111 ++++++++++++++++++ .../get-network-health-controller.spec.ts | 36 +++++- .../utils/network-health-mismatches.spec.ts | 83 +++++++++++++ 7 files changed, 313 insertions(+), 16 deletions(-) create mode 100644 .changeset/network-health-mismatch-warnings.md create mode 100644 src/utils/network-health-mismatches.ts create mode 100644 test/unit/utils/network-health-mismatches.spec.ts diff --git a/.changeset/network-health-mismatch-warnings.md b/.changeset/network-health-mismatch-warnings.md new file mode 100644 index 00000000..e771b1f4 --- /dev/null +++ b/.changeset/network-health-mismatch-warnings.md @@ -0,0 +1,7 @@ +--- +"nostream": minor +--- + +feat(admin): add NIP-11 vs probe mismatch warnings on Network Health + +Closes #798 diff --git a/resources/admin/assets/dashboard.css b/resources/admin/assets/dashboard.css index 0a22b138..ba2f6dc5 100644 --- a/resources/admin/assets/dashboard.css +++ b/resources/admin/assets/dashboard.css @@ -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; } diff --git a/resources/admin/assets/dashboard.js b/resources/admin/assets/dashboard.js index 18e785d0..493d9b8d 100644 --- a/resources/admin/assets/dashboard.js +++ b/resources/admin/assets/dashboard.js @@ -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' } @@ -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) }) } diff --git a/src/controllers/admin/get-network-health-controller.ts b/src/controllers/admin/get-network-health-controller.ts index 3d9f2622..bb11184f 100644 --- a/src/controllers/admin/get-network-health-controller.ts +++ b/src/controllers/admin/get-network-health-controller.ts @@ -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 { 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), + }) + + response.status(200).setHeader('content-type', 'application/json').send({ snapshot: enriched }) } } diff --git a/src/utils/network-health-mismatches.ts b/src/utils/network-health-mismatches.ts new file mode 100644 index 00000000..3b6d9cee --- /dev/null +++ b/src/utils/network-health-mismatches.ts @@ -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 +} + +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) { + 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.', + }) + } + } + + 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 = ( + snapshot: T, + context: NetworkHealthMismatchContext, +): T & { results: Array } => { + return { + ...snapshot, + results: snapshot.results.map((result) => ({ + ...result, + mismatches: collectNetworkHealthMismatches(result, context), + })), + } +} diff --git a/test/unit/controllers/admin/get-network-health-controller.spec.ts b/test/unit/controllers/admin/get-network-health-controller.spec.ts index 178504c2..3b77e4a3 100644 --- a/test/unit/controllers/admin/get-network-health-controller.spec.ts +++ b/test/unit/controllers/admin/get-network-health-controller.spec.ts @@ -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' @@ -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(), @@ -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', } @@ -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 () => { diff --git a/test/unit/utils/network-health-mismatches.spec.ts b/test/unit/utils/network-health-mismatches.spec.ts new file mode 100644 index 00000000..c594c726 --- /dev/null +++ b/test/unit/utils/network-health-mismatches.spec.ts @@ -0,0 +1,83 @@ +import { expect } from 'chai' + +import { StoredProbeResult } from '../../../src/@types/relay-probe-snapshot' +import { collectNetworkHealthMismatches } from '../../../src/utils/network-health-mismatches' + +const baseResult = (): StoredProbeResult => ({ + 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: 10, address: 'wss://relay.example.com/' } }, + nip11: { + status: 'ok', + durationMs: 1, + data: { + statusCode: 200, + supportedNips: [1, 11, 66], + limitation: {}, + }, + }, +}) + +describe('network-health-mismatches', () => { + it('warns when NIP-66 is missing from supported_nips', () => { + const result = baseResult() + result.nip11.data!.supportedNips = [1, 11] + + const mismatches = collectNetworkHealthMismatches(result, {}) + + expect(mismatches.some((entry) => entry.code === 'nip66-not-advertised')).to.equal(true) + }) + + it('warns when NIP-11 advertises auth but probe did not observe NIP-42', () => { + const result = baseResult() + result.nip11.data!.limitation = { authRequired: true } + + const mismatches = collectNetworkHealthMismatches(result, {}) + + expect(mismatches.some((entry) => entry.code === 'nip42-not-observed')).to.equal(true) + }) + + it('warns when probe observed NIP-42 but NIP-11 does not advertise auth', () => { + const result = baseResult() + result.wsRtt.data!.nip42AuthRequired = true + + const mismatches = collectNetworkHealthMismatches(result, {}) + + expect(mismatches.some((entry) => entry.code === 'nip42-not-advertised')).to.equal(true) + }) + + it('warns on public URL mismatch for single-target self monitoring', () => { + const result = baseResult() + result.target.relayUrl = 'wss://127.0.0.1:8008/' + result.target.wsUrl = 'wss://127.0.0.1:8008/' + + const mismatches = collectNetworkHealthMismatches(result, { + configuredRelayUrl: 'wss://relay.example.com/', + publicTargetKeys: new Set(['wss://relay.example.com/']), + }) + + expect(mismatches.some((entry) => entry.code === 'public-url')).to.equal(true) + }) + + it('does not warn on public URL when multiple public probe targets are configured', () => { + const result = baseResult() + result.target.relayUrl = 'wss://peer.example.com/' + result.target.wsUrl = 'wss://peer.example.com/' + + const mismatches = collectNetworkHealthMismatches(result, { + configuredRelayUrl: 'wss://relay.example.com/', + publicTargetKeys: new Set(['wss://relay.example.com/', 'wss://peer.example.com/']), + }) + + expect(mismatches.some((entry) => entry.code === 'public-url')).to.equal(false) + }) +}) From 3d7b7c76fc443f4aff50dfe06d0082080a584124 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Tue, 6 Oct 2026 22:18:51 +0530 Subject: [PATCH 2/2] fix(admin): tighten network-health mismatch rules per review Skip mirror peers for public-target checks, honor restricted_writes for NIP-42, avoid masking failed NIP-11 status, and persist probeContext on snapshots. --- resources/admin/assets/dashboard.js | 5 +- src/@types/relay-probe-snapshot.ts | 7 +++ src/app/relay-monitor-worker.ts | 7 +++ .../admin/get-network-health-controller.ts | 14 +++-- src/utils/network-health-mismatches.ts | 55 ++++++++++++++----- .../utils/network-health-mismatches.spec.ts | 52 +++++++++++++----- 6 files changed, 105 insertions(+), 35 deletions(-) diff --git a/resources/admin/assets/dashboard.js b/resources/admin/assets/dashboard.js index 493d9b8d..22b84848 100644 --- a/resources/admin/assets/dashboard.js +++ b/resources/admin/assets/dashboard.js @@ -540,7 +540,10 @@ }) const mismatches = Array.isArray(result.mismatches) ? result.mismatches : [] - if (mismatches.some((entry) => entry?.severity === 'warning')) { + if ( + result.nip11?.status === 'ok' && + mismatches.some((entry) => entry?.severity === 'warning') + ) { nip11.className = 'status-degraded' } diff --git a/src/@types/relay-probe-snapshot.ts b/src/@types/relay-probe-snapshot.ts index 867c3d2e..51f23d69 100644 --- a/src/@types/relay-probe-snapshot.ts +++ b/src/@types/relay-probe-snapshot.ts @@ -33,11 +33,18 @@ export interface StoredProbeResult { nip11: ProbeCheckResult } +/** 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 { diff --git a/src/app/relay-monitor-worker.ts b/src/app/relay-monitor-worker.ts index 1f5f848a..d7caab6e 100644 --- a/src/app/relay-monitor-worker.ts +++ b/src/app/relay-monitor-worker.ts @@ -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' @@ -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 diff --git a/src/controllers/admin/get-network-health-controller.ts b/src/controllers/admin/get-network-health-controller.ts index bb11184f..65738a91 100644 --- a/src/controllers/admin/get-network-health-controller.ts +++ b/src/controllers/admin/get-network-health-controller.ts @@ -3,8 +3,10 @@ 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 { + buildNetworkHealthMismatchContext, + enrichSnapshotWithMismatches, +} from '../../utils/network-health-mismatches' import { loadMergedSettings } from '../../utils/settings-config' export class GetAdminNetworkHealthController implements IController { @@ -22,10 +24,10 @@ export class GetAdminNetworkHealthController implements IController { } const settings = this.getSettings() - const enriched = enrichSnapshotWithMismatches(snapshot, { - configuredRelayUrl: settings.info?.relay_url, - publicTargetKeys: resolvePublicProbeTargetKeys(settings), - }) + const enriched = enrichSnapshotWithMismatches( + snapshot, + buildNetworkHealthMismatchContext(snapshot, settings), + ) response.status(200).setHeader('content-type', 'application/json').send({ snapshot: enriched }) } diff --git a/src/utils/network-health-mismatches.ts b/src/utils/network-health-mismatches.ts index 3b6d9cee..1e99fa4b 100644 --- a/src/utils/network-health-mismatches.ts +++ b/src/utils/network-health-mismatches.ts @@ -1,5 +1,7 @@ -import { StoredProbeResult } from '../@types/relay-probe-snapshot' +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' @@ -11,7 +13,7 @@ export interface NetworkHealthMismatch { export interface NetworkHealthMismatchContext { configuredRelayUrl?: string - /** Normalized keys from resolvePublicProbeTargetKeys (publish/probe set). */ + /** Normalized keys from resolvePublicProbeTargetKeys at probe time (or current settings). */ publicTargetKeys?: Set } @@ -27,6 +29,26 @@ const normalizeRelayUrlOrUndefined = (relayUrl: string | undefined): string | un } } +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 = {}, @@ -35,8 +57,15 @@ export const collectNetworkHealthMismatches = ( 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)) { + 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', @@ -45,11 +74,8 @@ export const collectNetworkHealthMismatches = ( } 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) { + if (isPublicTarget && configuredKey && probedKey && probedKey !== configuredKey) { mismatches.push({ code: 'public-url', severity: 'warning', @@ -57,11 +83,12 @@ export const collectNetworkHealthMismatches = ( }) } - if (nip11 && ws) { - const advertisesAuth = limitation?.authRequired === true - const observedAuth = ws.nip42AuthRequired === true || ws.nip42ChallengeObserved === true + if (nip11 && ws && isPublicTarget) { + const advertisesConnectionAuth = limitation?.authRequired === true + const advertisesWriteAuth = limitation?.restrictedWrites === true + const observedAuthRequired = ws.nip42AuthRequired === true - if (advertisesAuth && !observedAuth) { + if (advertisesConnectionAuth && !observedAuthRequired && ws.nip42ChallengeObserved !== true) { mismatches.push({ code: 'nip42-not-observed', severity: 'warning', @@ -69,11 +96,11 @@ export const collectNetworkHealthMismatches = ( }) } - if (!advertisesAuth && observedAuth) { + if (!advertisesConnectionAuth && !advertisesWriteAuth && observedAuthRequired) { mismatches.push({ code: 'nip42-not-advertised', severity: 'warning', - message: 'WebSocket probe observed NIP-42 but NIP-11 does not advertise auth-required.', + message: 'WebSocket probe observed NIP-42 auth-required but NIP-11 does not advertise auth or restricted writes.', }) } } diff --git a/test/unit/utils/network-health-mismatches.spec.ts b/test/unit/utils/network-health-mismatches.spec.ts index c594c726..d258635d 100644 --- a/test/unit/utils/network-health-mismatches.spec.ts +++ b/test/unit/utils/network-health-mismatches.spec.ts @@ -27,56 +27,80 @@ const baseResult = (): StoredProbeResult => ({ }, }) +const publicContext = { + configuredRelayUrl: 'wss://relay.example.com/', + publicTargetKeys: new Set(['wss://relay.example.com/']), +} + describe('network-health-mismatches', () => { - it('warns when NIP-66 is missing from supported_nips', () => { + it('warns when NIP-66 is missing from supported_nips on public targets', () => { const result = baseResult() result.nip11.data!.supportedNips = [1, 11] - const mismatches = collectNetworkHealthMismatches(result, {}) + const mismatches = collectNetworkHealthMismatches(result, publicContext) expect(mismatches.some((entry) => entry.code === 'nip66-not-advertised')).to.equal(true) }) + it('does not warn about NIP-66 on mirror-only probe targets', () => { + const result = baseResult() + result.target.relayUrl = 'wss://mirror.example.com/' + result.target.wsUrl = 'wss://mirror.example.com/' + result.nip11.data!.supportedNips = [1, 11] + + const mismatches = collectNetworkHealthMismatches(result, publicContext) + + expect(mismatches.some((entry) => entry.code === 'nip66-not-advertised')).to.equal(false) + expect(mismatches.some((entry) => entry.code === 'public-url')).to.equal(false) + }) + it('warns when NIP-11 advertises auth but probe did not observe NIP-42', () => { const result = baseResult() result.nip11.data!.limitation = { authRequired: true } - const mismatches = collectNetworkHealthMismatches(result, {}) + const mismatches = collectNetworkHealthMismatches(result, publicContext) expect(mismatches.some((entry) => entry.code === 'nip42-not-observed')).to.equal(true) }) - it('warns when probe observed NIP-42 but NIP-11 does not advertise auth', () => { + it('does not warn when restricted_writes explains observed NIP-42 on write', () => { const result = baseResult() + result.nip11.data!.limitation = { restrictedWrites: true } result.wsRtt.data!.nip42AuthRequired = true - const mismatches = collectNetworkHealthMismatches(result, {}) + const mismatches = collectNetworkHealthMismatches(result, publicContext) + + expect(mismatches.some((entry) => entry.code === 'nip42-not-advertised')).to.equal(false) + }) + + it('warns when probe observed NIP-42 auth-required without NIP-11 policy', () => { + const result = baseResult() + result.wsRtt.data!.nip42AuthRequired = true + + const mismatches = collectNetworkHealthMismatches(result, publicContext) expect(mismatches.some((entry) => entry.code === 'nip42-not-advertised')).to.equal(true) }) - it('warns on public URL mismatch for single-target self monitoring', () => { + it('warns on public URL mismatch for configured public targets', () => { const result = baseResult() result.target.relayUrl = 'wss://127.0.0.1:8008/' result.target.wsUrl = 'wss://127.0.0.1:8008/' const mismatches = collectNetworkHealthMismatches(result, { configuredRelayUrl: 'wss://relay.example.com/', - publicTargetKeys: new Set(['wss://relay.example.com/']), + publicTargetKeys: new Set(['wss://127.0.0.1:8008/']), }) expect(mismatches.some((entry) => entry.code === 'public-url')).to.equal(true) }) - it('does not warn on public URL when multiple public probe targets are configured', () => { + it('does not warn on public URL for static mirror peers', () => { const result = baseResult() - result.target.relayUrl = 'wss://peer.example.com/' - result.target.wsUrl = 'wss://peer.example.com/' + result.target.relayUrl = 'wss://mirror.example.com/' + result.target.wsUrl = 'wss://mirror.example.com/' - const mismatches = collectNetworkHealthMismatches(result, { - configuredRelayUrl: 'wss://relay.example.com/', - publicTargetKeys: new Set(['wss://relay.example.com/', 'wss://peer.example.com/']), - }) + const mismatches = collectNetworkHealthMismatches(result, publicContext) expect(mismatches.some((entry) => entry.code === 'public-url')).to.equal(false) })