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/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/routes/admin/index.ts b/src/routes/admin/index.ts index 0d9e019e..c2b9f9f6 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -3,6 +3,7 @@ import express, { json, Router } from 'express' import { createGetAdminNotificationDeliveryLogController } from '../../factories/controllers/get-admin-notification-delivery-log-controller-factory' 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' @@ -58,6 +59,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 index a1210ab2..4ab07115 100644 --- a/src/services/nip66-event-publisher.ts +++ b/src/services/nip66-event-publisher.ts @@ -13,6 +13,8 @@ import { buildMonitorRelayListEvent, buildRelayDiscoveryEvent, } from '../utils/nip66-events' +import { filterValidProbeTargets } from '../utils/relay-probe-targets' + const logger = createLogger('nip66-event-publisher') export const NIP66_MONITOR_BOOTSTRAPPED_KEY = 'nip66:monitor:bootstrapped' @@ -61,7 +63,13 @@ export class Nip66EventPublisher implements INip66EventPublisher { return } - const relayUrl = settings.info.relay_url + const { valid } = filterValidProbeTargets([settings.info.relay_url?.trim() ?? '']) + const [relayUrl] = valid + + if (!relayUrl) { + logger.warn('info.relay_url is not a valid relay URL; skipping NIP-66 monitor bootstrap') + return + } await this.persistSignedEvent(buildMonitorProfileEvent(monitorPubkey, createdAt), privkey) await this.persistSignedEvent(buildMonitorRelayListEvent(relayUrl, monitorPubkey, createdAt), privkey) 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..178504c2 --- /dev/null +++ b/test/unit/controllers/admin/get-network-health-controller.spec.ts @@ -0,0 +1,61 @@ +import chai from 'chai' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { IRelayProbeSnapshotStore, RelayProbeRunSnapshot } from '../../../../src/@types/relay-probe-snapshot' +import { GetAdminNetworkHealthController } from '../../../../src/controllers/admin/get-network-health-controller' + +chai.use(sinonChai) + +const { expect } = chai + +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 index c1b58e37..2f59b7b4 100644 --- a/test/unit/services/nip66-event-publisher.spec.ts +++ b/test/unit/services/nip66-event-publisher.spec.ts @@ -66,6 +66,20 @@ describe('Nip66EventPublisher', () => { expect(cache.setKey).to.have.been.calledWith(NIP66_MONITOR_BOOTSTRAPPED_KEY, 'b'.repeat(64)) }) + it('skips bootstrap when the configured relay URL is invalid', async () => { + const invalidSettings = { ...settings, info: { relay_url: 'not a relay url' } } as any + + await publisher.publishAfterProbe(snapshot, invalidSettings) + + const bootstrapKinds = [EventKinds.SET_METADATA, EventKinds.RELAY_LIST] + const bootstrapUpserts = eventRepository.upsert + .getCalls() + .filter((call) => bootstrapKinds.includes(call.args[0].kind)) + + expect(bootstrapUpserts).to.be.empty + expect(cache.setKey).to.not.have.been.called + }) + it('re-bootstraps when the monitor pubkey changes', async () => { cache.getKey.resolves('a'.repeat(64))