Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/admin-network-health-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": minor
---

feat(admin): add GET /admin/network-health endpoint
14 changes: 14 additions & 0 deletions src/controllers/admin/get-network-health-controller.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const snapshot = await this.snapshotStore.getLatest()

response.status(200).setHeader('content-type', 'application/json').send({ snapshot })
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
7 changes: 7 additions & 0 deletions src/routes/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -58,6 +59,12 @@ router.get(
adminAuthMiddleware,
withAdminController(createGetAdminMetricsController),
)
router.get(
'/network-health',
adminRateLimitMiddleware,
adminAuthMiddleware,
withAdminController(createGetAdminNetworkHealthController),
)
router.get(
'/settings',
adminRateLimitMiddleware,
Expand Down
10 changes: 9 additions & 1 deletion src/services/nip66-event-publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
61 changes: 61 additions & 0 deletions test/unit/controllers/admin/get-network-health-controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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<IRelayProbeSnapshotStore>
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 })
})
})
18 changes: 17 additions & 1 deletion test/unit/routes/admin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -96,6 +109,7 @@ describe('admin router', () => {
const stopServer = async () => {
createGetAdminHealthControllerStub?.restore()
createGetAdminMetricsControllerStub?.restore()
createGetAdminNetworkHealthControllerStub?.restore()
createSettingsStub?.restore()
rateLimiterMiddlewareStub?.restore()
adminRateLimitMiddlewareStub?.restore()
Expand Down Expand Up @@ -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 () => {
Expand Down
14 changes: 14 additions & 0 deletions test/unit/services/nip66-event-publisher.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
Loading