diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml
index 115ea8454..2d85a1fee 100755
--- a/resources/default-settings.yaml
+++ b/resources/default-settings.yaml
@@ -122,6 +122,9 @@ nip66:
# Public WebSocket URLs to probe. Empty list defaults to info.relay_url.
targets: []
dnsCacheTtlSeconds: 300
+ historyRetentionRuns: 168
+ # Optional max age in hours for probe history; 0 disables age-based trimming (run count only).
+ historyRetentionHours: 0
wot:
# Web of Trust graph. When enabled, the relay builds a trust graph rooted
# at seedPubkey from NIP-02 contact lists, updated in real time as kind-3
diff --git a/src/@types/adapters.ts b/src/@types/adapters.ts
index f84f542cf..853810828 100644
--- a/src/@types/adapters.ts
+++ b/src/@types/adapters.ts
@@ -29,6 +29,8 @@ export interface ICacheAdapter {
setKeyIfNotExists(key: string, value: string, expirySeconds?: number): Promise
addToSortedSet(key: string, set: Record | Record[]): Promise
removeRangeByScoreFromSortedSet(key: string, min: number, max: number): Promise
+ removeRangeByRankFromSortedSet(key: string, start: number, stop: number): Promise
+ getSortedSetSize(key: string): Promise
getRangeFromSortedSet(key: string, start: number, stop: number): Promise
setKeyExpiry(key: string, expiry: number): Promise
diff --git a/src/@types/relay-probe-snapshot.ts b/src/@types/relay-probe-snapshot.ts
index 51f23d693..3e12df6f5 100644
--- a/src/@types/relay-probe-snapshot.ts
+++ b/src/@types/relay-probe-snapshot.ts
@@ -47,6 +47,11 @@ export interface RelayProbeRunSnapshot {
probeContext?: RelayProbeRunContext
}
+export interface RelayProbeHistoryRetention {
+ maxRuns: number
+ maxAgeHours?: number
+}
+
export interface IRelayProbeSnapshotStore {
saveLatest(snapshot: RelayProbeRunSnapshot, expirySeconds?: number): Promise
getLatest(): Promise
diff --git a/src/@types/settings.ts b/src/@types/settings.ts
index 885ecdb11..0dcb3a744 100644
--- a/src/@types/settings.ts
+++ b/src/@types/settings.ts
@@ -356,6 +356,8 @@ export interface Nip66Settings {
* Optional NIP-52 geohash describing this monitor's network vantage for kind 10166 events.
*/
geohash?: string
+ historyRetentionRuns?: number
+ historyRetentionHours?: number
}
export interface Nip05Settings {
diff --git a/src/adapters/redis-adapter.ts b/src/adapters/redis-adapter.ts
index 01852ea47..35c51319d 100644
--- a/src/adapters/redis-adapter.ts
+++ b/src/adapters/redis-adapter.ts
@@ -84,6 +84,18 @@ export class RedisAdapter implements ICacheAdapter {
return this.client.zRemRangeByScore(key, min, max)
}
+ public async removeRangeByRankFromSortedSet(key: string, start: number, stop: number): Promise {
+ await this.connection
+ logger('remove rank %d..%d from sorted set %s', start, stop, key)
+ return this.client.zRemRangeByRank(key, start, stop)
+ }
+
+ public async getSortedSetSize(key: string): Promise {
+ await this.connection
+ logger('get sorted set size for %s', key)
+ return this.client.zCard(key)
+ }
+
public async getRangeFromSortedSet(key: string, min: number, max: number): Promise {
await this.connection
logger('get %d..%d range from sorted set %s', min, max, key)
diff --git a/src/app/relay-monitor-worker.ts b/src/app/relay-monitor-worker.ts
index d7caab6e3..e8a7b2f44 100644
--- a/src/app/relay-monitor-worker.ts
+++ b/src/app/relay-monitor-worker.ts
@@ -7,7 +7,8 @@ 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 { RelayProbeHistoryStore } from '../utils/relay-probe-history'
+import { getEffectiveProbeIntervalSeconds, getNip66HistoryRetention, getProbeIntervalMs } from '../utils/nip66-schedule'
import { getMonitorPrivateKey } from '../utils/monitor-identity'
import { runProbe } from '../utils/relay-probe'
import { ProbeOptions, ProbeResult } from '../utils/relay-probe/types'
@@ -39,6 +40,7 @@ export class RelayMonitorWorker implements IRunnable {
private readonly snapshotStore: IRelayProbeSnapshotStore,
private readonly probeRunner: RunProbeFn = runProbe,
private readonly eventPublisher?: INip66EventPublisher,
+ private readonly historyStore?: RelayProbeHistoryStore,
) {
this.process
.on('SIGINT', this.onExit.bind(this))
@@ -136,6 +138,13 @@ export class RelayMonitorWorker implements IRunnable {
const expirySeconds = getEffectiveProbeIntervalSeconds(currentSettings) * 2
await this.snapshotStore.saveLatest(snapshot, expirySeconds)
+ if (this.historyStore) {
+ try {
+ await this.historyStore.append(snapshot, getNip66HistoryRetention(currentSettings))
+ } catch (error) {
+ logger.error('failed to save probe history: %o', error)
+ }
+ }
logger('saved probe snapshot for %d target(s) with status %s', valid.length, snapshot.status)
if (this.eventPublisher) {
diff --git a/src/controllers/admin/get-network-health-history-controller.ts b/src/controllers/admin/get-network-health-history-controller.ts
new file mode 100644
index 000000000..c52e6f5a6
--- /dev/null
+++ b/src/controllers/admin/get-network-health-history-controller.ts
@@ -0,0 +1,49 @@
+import { Request, Response } from 'express'
+
+import { IController } from '../../@types/controllers'
+import { Settings } from '../../@types/settings'
+import {
+ buildNetworkHealthMismatchContext,
+ enrichSnapshotWithMismatches,
+} from '../../utils/network-health-mismatches'
+import { getNip66HistoryRetention } from '../../utils/nip66-schedule'
+import { MAX_PROBE_HISTORY_API_LIMIT, RelayProbeHistoryStore } from '../../utils/relay-probe-history'
+import { loadMergedSettings } from '../../utils/settings-config'
+
+export const DEFAULT_NETWORK_HEALTH_HISTORY_LIMIT = 24
+
+export const parseNetworkHealthHistoryLimit = (raw: unknown): number | null => {
+ if (raw === undefined) {
+ return DEFAULT_NETWORK_HEALTH_HISTORY_LIMIT
+ }
+
+ const parsed = Number(raw)
+ if (!Number.isFinite(parsed) || parsed < 1) {
+ return null
+ }
+
+ return Math.min(Math.floor(parsed), MAX_PROBE_HISTORY_API_LIMIT)
+}
+
+export class GetAdminNetworkHealthHistoryController implements IController {
+ public constructor(
+ private readonly historyStore: RelayProbeHistoryStore,
+ private readonly getSettings: () => Settings = loadMergedSettings,
+ ) {}
+
+ public async handleRequest(request: Request, response: Response): Promise {
+ const limit = parseNetworkHealthHistoryLimit(request.query.limit)
+ if (limit === null) {
+ response.status(400).setHeader('content-type', 'application/json').send({ error: 'limit must be a positive integer' })
+ return
+ }
+
+ const settings = this.getSettings()
+ const runs = await this.historyStore.list(limit, getNip66HistoryRetention(settings))
+ const enriched = runs.map((snapshot) =>
+ enrichSnapshotWithMismatches(snapshot, buildNetworkHealthMismatchContext(snapshot, settings)),
+ )
+
+ response.status(200).setHeader('content-type', 'application/json').send({ runs: enriched })
+ }
+}
diff --git a/src/factories/controllers/get-admin-network-health-history-controller-factory.ts b/src/factories/controllers/get-admin-network-health-history-controller-factory.ts
new file mode 100644
index 000000000..ac9146215
--- /dev/null
+++ b/src/factories/controllers/get-admin-network-health-history-controller-factory.ts
@@ -0,0 +1,11 @@
+import { RedisAdapter } from '../../adapters/redis-adapter'
+import { IController } from '../../@types/controllers'
+import { getCacheClient } from '../../cache/client'
+import { GetAdminNetworkHealthHistoryController } from '../../controllers/admin/get-network-health-history-controller'
+import { RelayProbeHistoryStore } from '../../utils/relay-probe-history'
+
+export const createGetAdminNetworkHealthHistoryController = (): IController => {
+ const historyStore = new RelayProbeHistoryStore(new RedisAdapter(getCacheClient()))
+
+ return new GetAdminNetworkHealthHistoryController(historyStore)
+}
diff --git a/src/factories/relay-monitor-worker-factory.ts b/src/factories/relay-monitor-worker-factory.ts
index d2f0afe2e..0dae13289 100644
--- a/src/factories/relay-monitor-worker-factory.ts
+++ b/src/factories/relay-monitor-worker-factory.ts
@@ -5,14 +5,16 @@ import { getMasterDbClient, getReadReplicaDbClient } from '../database/client'
import { createEventRepository } from './event-repository-factory'
import { createSettings } from './settings-factory'
import { Nip66EventPublisher } from '../services/nip66-event-publisher'
+import { RelayProbeHistoryStore } from '../utils/relay-probe-history'
import { RelayProbeSnapshotStore } from '../utils/relay-probe-snapshot'
import { runProbe } from '../utils/relay-probe'
export const relayMonitorWorkerFactory = () => {
const cache = new RedisAdapter(getCacheClient())
const snapshotStore = new RelayProbeSnapshotStore(cache)
+ const historyStore = new RelayProbeHistoryStore(cache)
const eventRepository = createEventRepository(getMasterDbClient(), getReadReplicaDbClient())
const eventPublisher = new Nip66EventPublisher(eventRepository, cache)
- return new RelayMonitorWorker(process, createSettings, snapshotStore, runProbe, eventPublisher)
+ return new RelayMonitorWorker(process, createSettings, snapshotStore, runProbe, eventPublisher, historyStore)
}
diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts
index 0f73629f0..3f2740f27 100644
--- a/src/routes/admin/index.ts
+++ b/src/routes/admin/index.ts
@@ -6,6 +6,7 @@ import { createPatchAdminNotificationsController } from '../../factories/control
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 { createGetAdminNetworkHealthHistoryController } from '../../factories/controllers/get-admin-network-health-history-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'
@@ -67,6 +68,12 @@ router.get(
adminAuthMiddleware,
withAdminController(createGetAdminNetworkHealthController),
)
+router.get(
+ '/network-health/history',
+ adminRateLimitMiddleware,
+ adminAuthMiddleware,
+ withAdminController(createGetAdminNetworkHealthHistoryController),
+)
router.get(
'/settings',
adminRateLimitMiddleware,
diff --git a/src/utils/nip66-schedule.ts b/src/utils/nip66-schedule.ts
index aaf7aff25..2c2cf416b 100644
--- a/src/utils/nip66-schedule.ts
+++ b/src/utils/nip66-schedule.ts
@@ -2,6 +2,7 @@ import { Settings } from '../@types/settings'
export const DEFAULT_PROBE_INTERVAL_SECONDS = 3600
export const MIN_PROBE_INTERVAL_SECONDS = 60
+export const DEFAULT_HISTORY_RETENTION_RUNS = 168
export const getEffectiveProbeIntervalSeconds = (settings: Settings): number => {
const configured = settings.nip66?.probeIntervalSeconds ?? DEFAULT_PROBE_INTERVAL_SECONDS
@@ -12,3 +13,11 @@ export const getEffectiveProbeIntervalSeconds = (settings: Settings): number =>
export const getProbeIntervalMs = (settings: Settings): number => {
return getEffectiveProbeIntervalSeconds(settings) * 1000
}
+
+export const getNip66HistoryRetention = (settings: Settings) => {
+ const maxAgeHours = settings.nip66?.historyRetentionHours
+ return {
+ maxRuns: settings.nip66?.historyRetentionRuns ?? DEFAULT_HISTORY_RETENTION_RUNS,
+ maxAgeHours: typeof maxAgeHours === 'number' && maxAgeHours > 0 ? maxAgeHours : undefined,
+ }
+}
diff --git a/src/utils/relay-probe-history.ts b/src/utils/relay-probe-history.ts
new file mode 100644
index 000000000..30e4db54f
--- /dev/null
+++ b/src/utils/relay-probe-history.ts
@@ -0,0 +1,57 @@
+import { ICacheAdapter } from '../@types/adapters'
+import { RelayProbeHistoryRetention, RelayProbeRunSnapshot } from '../@types/relay-probe-snapshot'
+
+export const RELAY_PROBE_HISTORY_KEY = 'nip66:snapshot:history'
+export const MAX_PROBE_HISTORY_API_LIMIT = 500
+
+const jsonReplacer = (_key: string, value: unknown): unknown => {
+ if (value instanceof Date) {
+ return value.toISOString()
+ }
+
+ return value
+}
+
+export class RelayProbeHistoryStore {
+ public constructor(private readonly cache: ICacheAdapter) {}
+
+ public async append(snapshot: RelayProbeRunSnapshot, retention: RelayProbeHistoryRetention): Promise {
+ const score = Date.parse(snapshot.runAt)
+ if (!Number.isFinite(score)) {
+ return
+ }
+
+ const payload = JSON.stringify(snapshot, jsonReplacer)
+ await this.cache.addToSortedSet(RELAY_PROBE_HISTORY_KEY, { [payload]: String(score) })
+ await this.trim(retention)
+ }
+
+ public async list(limit: number, retention?: RelayProbeHistoryRetention): Promise {
+ if (retention) {
+ await this.trim(retention)
+ }
+
+ const clamped = Math.min(Math.max(Math.floor(limit), 1), MAX_PROBE_HISTORY_API_LIMIT)
+ const raw = await this.cache.getRangeFromSortedSet(RELAY_PROBE_HISTORY_KEY, -clamped, -1)
+
+ return raw
+ .map((entry) => JSON.parse(entry) as RelayProbeRunSnapshot)
+ .reverse()
+ }
+
+ private async trim(retention: RelayProbeHistoryRetention): Promise {
+ const maxRuns = Math.max(1, Math.floor(retention.maxRuns))
+ // Drop oldest ranks without ZCARD: safe under concurrent trim (idempotent excess removal).
+ await this.cache.removeRangeByRankFromSortedSet(
+ RELAY_PROBE_HISTORY_KEY,
+ 0,
+ -(maxRuns + 1),
+ )
+
+ const maxAgeHours = retention.maxAgeHours
+ if (typeof maxAgeHours === 'number' && maxAgeHours > 0) {
+ const minScore = Date.now() - maxAgeHours * 3600 * 1000
+ await this.cache.removeRangeByScoreFromSortedSet(RELAY_PROBE_HISTORY_KEY, 0, minScore)
+ }
+ }
+}
diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts
index 93fb91498..e30453f4e 100644
--- a/src/utils/settings-config.ts
+++ b/src/utils/settings-config.ts
@@ -621,6 +621,20 @@ export const validateSettings = (settings: Settings): ValidationIssue[] => {
})
}
+ const nip66 = settings.nip66
+ if (nip66?.historyRetentionRuns !== undefined && !(nip66.historyRetentionRuns >= 1)) {
+ issues.push({ path: 'nip66.historyRetentionRuns', message: 'historyRetentionRuns must be at least 1' })
+ }
+ if (
+ nip66?.historyRetentionHours !== undefined &&
+ !(nip66.historyRetentionHours === 0 || nip66.historyRetentionHours >= 1)
+ ) {
+ issues.push({
+ path: 'nip66.historyRetentionHours',
+ message: 'historyRetentionHours must be 0 (disabled) or at least 1',
+ })
+ }
+
const pow = settings.limits?.event?.pow
if (pow?.enabled) {
if (!(pow.floorBits >= 0) || !(pow.floorBits <= pow.ceilingBits)) {
diff --git a/test/unit/adapters/redis-adapter.spec.ts b/test/unit/adapters/redis-adapter.spec.ts
index 45fb8462b..6a5ef10c1 100644
--- a/test/unit/adapters/redis-adapter.spec.ts
+++ b/test/unit/adapters/redis-adapter.spec.ts
@@ -29,6 +29,8 @@ describe('RedisAdapter', () => {
get: sandbox.stub(),
set: sandbox.stub(),
zRemRangeByScore: sandbox.stub(),
+ zRemRangeByRank: sandbox.stub(),
+ zCard: sandbox.stub(),
zRange: sandbox.stub(),
expire: sandbox.stub(),
zAdd: sandbox.stub(),
@@ -163,6 +165,28 @@ describe('RedisAdapter', () => {
})
})
+ describe('removeRangeByRankFromSortedSet', () => {
+ it('calls client.zRemRangeByRank with correct arguments', async () => {
+ client.zRemRangeByRank.resolves(2)
+
+ const result = await adapter.removeRangeByRankFromSortedSet('sorted-key', 0, 5)
+
+ expect(client.zRemRangeByRank).to.have.been.calledOnceWithExactly('sorted-key', 0, 5)
+ expect(result).to.equal(2)
+ })
+ })
+
+ describe('getSortedSetSize', () => {
+ it('calls client.zCard with the key', async () => {
+ client.zCard.resolves(4)
+
+ const result = await adapter.getSortedSetSize('sorted-key')
+
+ expect(client.zCard).to.have.been.calledOnceWithExactly('sorted-key')
+ expect(result).to.equal(4)
+ })
+ })
+
describe('getRangeFromSortedSet', () => {
it('calls client.zRange with correct arguments', async () => {
client.zRange.resolves(['a', 'b', 'c'])
diff --git a/test/unit/app/relay-monitor-worker.spec.ts b/test/unit/app/relay-monitor-worker.spec.ts
index 7d4df9b2d..a8c2d84e7 100644
--- a/test/unit/app/relay-monitor-worker.spec.ts
+++ b/test/unit/app/relay-monitor-worker.spec.ts
@@ -28,6 +28,10 @@ describe('RelayMonitorWorker', () => {
getLatest: Sinon.SinonStub
}
let probeRunner: Sinon.SinonStub
+ let historyStore: {
+ append: Sinon.SinonStub
+ list: Sinon.SinonStub
+ }
const probeResult = (): ProbeResult =>
({
@@ -77,9 +81,20 @@ describe('RelayMonitorWorker', () => {
saveLatest: sandbox.stub().resolves(),
getLatest: sandbox.stub().resolves(null),
}
+ historyStore = {
+ append: sandbox.stub().resolves(),
+ list: sandbox.stub().resolves([]),
+ }
probeRunner = sandbox.stub().resolves(probeResult())
- worker = new RelayMonitorWorker(fakeProcess as unknown as NodeJS.Process, settings, snapshotStore, probeRunner)
+ worker = new RelayMonitorWorker(
+ fakeProcess as unknown as NodeJS.Process,
+ settings,
+ snapshotStore,
+ probeRunner,
+ undefined,
+ historyStore as any,
+ )
})
afterEach(() => {
@@ -102,17 +117,21 @@ describe('RelayMonitorWorker', () => {
worker.run()
await Promise.resolve()
+ await Promise.resolve()
expect(probeRunner).to.have.been.calledOnceWith('wss://relay.example.com', buildProbeOptions(settingsState))
expect(snapshotStore.saveLatest).to.have.been.calledOnce
+ expect(historyStore.append).to.have.been.calledOnce
probeRunner.resetHistory()
snapshotStore.saveLatest.resetHistory()
+ historyStore.append.resetHistory()
await clock.tickAsync(60_000)
expect(probeRunner).to.have.been.calledOnce
expect(snapshotStore.saveLatest).to.have.been.calledOnce
+ expect(historyStore.append).to.have.been.calledOnce
clock.restore()
})
@@ -176,8 +195,33 @@ describe('RelayMonitorWorker', () => {
worker.run()
await Promise.resolve()
await Promise.resolve()
+ await Promise.resolve()
expect(eventPublisher.publishAfterProbe).to.have.been.calledOnce
expect(eventPublisher.publishAfterProbe.firstCall.args[0].status).to.equal('ok')
})
+
+ it('still publishes NIP-66 events when probe history append fails', async () => {
+ const eventPublisher = {
+ publishAfterProbe: sandbox.stub().resolves(),
+ }
+
+ historyStore.append.rejects(new Error('redis unavailable'))
+
+ worker = new RelayMonitorWorker(
+ fakeProcess as unknown as NodeJS.Process,
+ settings,
+ snapshotStore,
+ probeRunner,
+ eventPublisher,
+ historyStore as any,
+ )
+
+ worker.run()
+ await Promise.resolve()
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(eventPublisher.publishAfterProbe).to.have.been.calledOnce
+ })
})
diff --git a/test/unit/controllers/admin/get-network-health-history-controller.spec.ts b/test/unit/controllers/admin/get-network-health-history-controller.spec.ts
new file mode 100644
index 000000000..bcf86cd14
--- /dev/null
+++ b/test/unit/controllers/admin/get-network-health-history-controller.spec.ts
@@ -0,0 +1,82 @@
+import chai from 'chai'
+import Sinon from 'sinon'
+import sinonChai from 'sinon-chai'
+
+import { Settings } from '../../../../src/@types/settings'
+import { RelayProbeRunSnapshot } from '../../../../src/@types/relay-probe-snapshot'
+import {
+ GetAdminNetworkHealthHistoryController,
+ parseNetworkHealthHistoryLimit,
+} from '../../../../src/controllers/admin/get-network-health-history-controller'
+import { RelayProbeHistoryStore } from '../../../../src/utils/relay-probe-history'
+
+chai.use(sinonChai)
+
+const { expect } = chai
+
+describe('GetAdminNetworkHealthHistoryController', () => {
+ let historyStore: Sinon.SinonStubbedInstance
+ let controller: GetAdminNetworkHealthHistoryController
+ let response: {
+ status: Sinon.SinonStub
+ setHeader: Sinon.SinonStub
+ send: Sinon.SinonStub
+ }
+
+ const settings = {
+ info: { relay_url: 'wss://relay.example.com/' },
+ nip66: { enabled: true, targets: [] },
+ } as Settings
+
+ const snapshot: RelayProbeRunSnapshot = {
+ runAt: '2026-01-01T00:00:00.000Z',
+ targets: ['wss://relay.example.com'],
+ results: [],
+ status: 'ok',
+ }
+
+ beforeEach(() => {
+ historyStore = {
+ append: Sinon.stub(),
+ list: Sinon.stub().resolves([snapshot]),
+ } as any
+
+ controller = new GetAdminNetworkHealthHistoryController(historyStore, () => settings)
+
+ response = {
+ status: Sinon.stub().returnsThis(),
+ setHeader: Sinon.stub().returnsThis(),
+ send: Sinon.stub().returnsThis(),
+ }
+ })
+
+ it('returns bounded probe history newest first', async () => {
+ await controller.handleRequest({ query: { limit: '5' } } as any, response as any)
+
+ expect(historyStore.list).to.have.been.calledOnce
+ expect(historyStore.list.firstCall.args[0]).to.equal(5)
+ expect(historyStore.list.firstCall.args[1]).to.deep.equal({ maxRuns: 168, maxAgeHours: undefined })
+ expect(response.status).to.have.been.calledOnceWithExactly(200)
+ const payload = response.send.firstCall.args[0] as { runs: RelayProbeRunSnapshot[] }
+ expect(payload.runs).to.have.length(1)
+ expect(payload.runs[0].runAt).to.equal(snapshot.runAt)
+ })
+
+ it('rejects invalid limit query values', async () => {
+ await controller.handleRequest({ query: { limit: '0' } } as any, response as any)
+
+ expect(response.status).to.have.been.calledOnceWithExactly(400)
+ expect(historyStore.list).not.to.have.been.called
+ })
+})
+
+describe('parseNetworkHealthHistoryLimit', () => {
+ it('defaults when limit is omitted', () => {
+ expect(parseNetworkHealthHistoryLimit(undefined)).to.equal(24)
+ })
+
+ it('rejects non-positive limits', () => {
+ expect(parseNetworkHealthHistoryLimit('abc')).to.equal(null)
+ expect(parseNetworkHealthHistoryLimit(0)).to.equal(null)
+ })
+})
diff --git a/test/unit/routes/admin.spec.ts b/test/unit/routes/admin.spec.ts
index de96ae294..82ba4f77b 100644
--- a/test/unit/routes/admin.spec.ts
+++ b/test/unit/routes/admin.spec.ts
@@ -204,14 +204,18 @@ describe('admin router', () => {
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 })
+ const networkHealthHistoryResponse = await axios.get(`${baseUrl}/network-health/history`, {
+ validateStatus: () => true,
+ })
const notificationsResponse = await axios.get(`${baseUrl}/notifications`, { validateStatus: () => true })
expect(sessionResponse.status).to.equal(401)
expect(healthResponse.status).to.equal(401)
expect(metricsResponse.status).to.equal(401)
expect(networkHealthResponse.status).to.equal(401)
+ expect(networkHealthHistoryResponse.status).to.equal(401)
expect(notificationsResponse.status).to.equal(401)
- expect(rateLimiterMiddlewareStub.callCount).to.equal(5)
+ expect(rateLimiterMiddlewareStub.callCount).to.equal(6)
})
it('authenticates a protected route with a signed NIP-98 event', async () => {
diff --git a/test/unit/utils/nip66-schedule.spec.ts b/test/unit/utils/nip66-schedule.spec.ts
index 522a07751..d7b759f86 100644
--- a/test/unit/utils/nip66-schedule.spec.ts
+++ b/test/unit/utils/nip66-schedule.spec.ts
@@ -2,7 +2,9 @@ import { expect } from 'chai'
import { Settings } from '../../../src/@types/settings'
import {
+ DEFAULT_HISTORY_RETENTION_RUNS,
getEffectiveProbeIntervalSeconds,
+ getNip66HistoryRetention,
getProbeIntervalMs,
MIN_PROBE_INTERVAL_SECONDS,
} from '../../../src/utils/nip66-schedule'
@@ -22,4 +24,23 @@ describe('nip66-schedule', () => {
expect(getEffectiveProbeIntervalSeconds(settings(120))).to.equal(120)
expect(getProbeIntervalMs(settings(120))).to.equal(120_000)
})
+
+ it('derives history retention defaults and overrides', () => {
+ expect(getNip66HistoryRetention({} as Settings)).to.deep.equal({
+ maxRuns: DEFAULT_HISTORY_RETENTION_RUNS,
+ maxAgeHours: undefined,
+ })
+
+ expect(
+ getNip66HistoryRetention({
+ nip66: { historyRetentionRuns: 48, historyRetentionHours: 72 },
+ } as Settings),
+ ).to.deep.equal({ maxRuns: 48, maxAgeHours: 72 })
+
+ expect(
+ getNip66HistoryRetention({
+ nip66: { historyRetentionHours: 0 },
+ } as Settings),
+ ).to.deep.equal({ maxRuns: DEFAULT_HISTORY_RETENTION_RUNS, maxAgeHours: undefined })
+ })
})
diff --git a/test/unit/utils/relay-probe-history.spec.ts b/test/unit/utils/relay-probe-history.spec.ts
new file mode 100644
index 000000000..a7407a92c
--- /dev/null
+++ b/test/unit/utils/relay-probe-history.spec.ts
@@ -0,0 +1,103 @@
+import { expect } from 'chai'
+import Sinon from 'sinon'
+
+import { RelayProbeRunSnapshot } from '../../../src/@types/relay-probe-snapshot'
+import {
+ RELAY_PROBE_HISTORY_KEY,
+ RelayProbeHistoryStore,
+} from '../../../src/utils/relay-probe-history'
+import { serializeProbeResults } from '../../../src/utils/relay-probe-snapshot'
+import { ProbeResult } from '../../../src/utils/relay-probe/types'
+
+describe('relay-probe-history', () => {
+ let cache: {
+ addToSortedSet: Sinon.SinonStub
+ getRangeFromSortedSet: Sinon.SinonStub
+ getSortedSetSize: Sinon.SinonStub
+ removeRangeByRankFromSortedSet: Sinon.SinonStub
+ removeRangeByScoreFromSortedSet: Sinon.SinonStub
+ }
+ let store: RelayProbeHistoryStore
+
+ const sampleResult = (wsStatus: 'ok' | 'error'): ProbeResult =>
+ ({
+ 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: new Date('2026-01-01T00:00:00.000Z'),
+ dns: { status: 'ok', durationMs: 1 },
+ tls: { status: 'ok', durationMs: 1 },
+ wsRtt: { status: wsStatus, durationMs: 1 },
+ nip11: { status: 'ok', durationMs: 1 },
+ }) as ProbeResult
+
+ beforeEach(() => {
+ cache = {
+ addToSortedSet: Sinon.stub().resolves(1),
+ getRangeFromSortedSet: Sinon.stub().resolves([]),
+ getSortedSetSize: Sinon.stub().resolves(0),
+ removeRangeByRankFromSortedSet: Sinon.stub().resolves(0),
+ removeRangeByScoreFromSortedSet: Sinon.stub().resolves(0),
+ }
+
+ store = new RelayProbeHistoryStore(cache as any)
+ })
+
+ it('appends probe runs to bounded history', async () => {
+ const snapshot: RelayProbeRunSnapshot = {
+ runAt: '2026-01-01T00:00:00.000Z',
+ targets: ['wss://relay.example.com'],
+ results: serializeProbeResults([sampleResult('ok')]),
+ status: 'ok',
+ }
+
+ await store.append(snapshot, { maxRuns: 1 })
+
+ expect(cache.addToSortedSet).to.have.been.calledOnce
+ expect(cache.removeRangeByRankFromSortedSet).to.have.been.calledOnceWith(
+ RELAY_PROBE_HISTORY_KEY,
+ 0,
+ -2,
+ )
+ })
+
+ it('returns the newest history entries up to the requested limit', async () => {
+ const older: RelayProbeRunSnapshot = {
+ runAt: '2026-01-01T00:00:00.000Z',
+ targets: [],
+ results: [],
+ status: 'partial',
+ }
+ const newer: RelayProbeRunSnapshot = {
+ runAt: '2026-01-02T00:00:00.000Z',
+ targets: [],
+ results: [],
+ status: 'ok',
+ }
+
+ cache.getRangeFromSortedSet.resolves([JSON.stringify(older), JSON.stringify(newer)])
+
+ const history = await store.list(2)
+
+ expect(cache.getRangeFromSortedSet).to.have.been.calledOnceWith(RELAY_PROBE_HISTORY_KEY, -2, -1)
+ expect(history.map((entry) => entry.runAt)).to.deep.equal([newer.runAt, older.runAt])
+ })
+
+ it('trims expired runs when listing with an age retention limit', async () => {
+ cache.getRangeFromSortedSet.resolves([])
+
+ await store.list(10, { maxRuns: 168, maxAgeHours: 24 })
+
+ expect(cache.removeRangeByScoreFromSortedSet).to.have.been.calledOnce
+ const [key, minScore, maxScore] = cache.removeRangeByScoreFromSortedSet.firstCall.args
+ expect(key).to.equal(RELAY_PROBE_HISTORY_KEY)
+ expect(minScore).to.equal(0)
+ expect(maxScore).to.be.a('number')
+ expect(maxScore).to.be.lessThan(Date.now())
+ })
+})
diff --git a/test/unit/utils/settings-config.spec.ts b/test/unit/utils/settings-config.spec.ts
index d9179f35d..efca05716 100644
--- a/test/unit/utils/settings-config.spec.ts
+++ b/test/unit/utils/settings-config.spec.ts
@@ -98,6 +98,17 @@ describe('settings-config', () => {
expect(issues.some((issue) => issue.path === 'network')).to.equal(true)
})
+ it('rejects invalid nip66 history retention settings', () => {
+ const issues = validateSettings({
+ info: { relay_url: 'wss://test.relay', name: 'test' },
+ network: {},
+ nip66: { historyRetentionRuns: 0, historyRetentionHours: 0 },
+ } as any)
+
+ expect(issues.some((issue) => issue.path === 'nip66.historyRetentionRuns')).to.equal(true)
+ expect(issues.some((issue) => issue.path === 'nip66.historyRetentionHours')).to.equal(false)
+ })
+
describe('rate limit period validation', () => {
const baseSettings = () =>
({
diff --git a/test/unit/utils/settings.spec.ts b/test/unit/utils/settings.spec.ts
index 7cbab5f03..72020ae02 100644
--- a/test/unit/utils/settings.spec.ts
+++ b/test/unit/utils/settings.spec.ts
@@ -277,6 +277,8 @@ describe('SettingsStatic', () => {
expect(defaults).to.have.nested.property('nip66.timeouts.nip11Ms', 10_000)
expect(defaults).to.have.nested.property('nip66.targets').that.deep.equals([])
expect(defaults).to.have.nested.property('nip66.dnsCacheTtlSeconds', 300)
+ expect(defaults).to.have.nested.property('nip66.historyRetentionRuns', 168)
+ expect(defaults).to.have.nested.property('nip66.historyRetentionHours', 0)
})
it('user config nip66 block overrides defaults', () => {