Download a redacted support bundle: version, health, settings, workers, mirrors, and latest probe snapshot.
+ Download a redacted support bundle: version, health, settings, workers, and latest probe snapshot.
Download JSON
diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml
index a94c23ef2..9b33f3489 100755
--- a/resources/default-settings.yaml
+++ b/resources/default-settings.yaml
@@ -185,8 +185,6 @@ workers:
eventStore:
# Only postgres for now. Changing it needs a restart.
backend: postgres
-mirroring:
- static: []
dvm:
workers: []
limits:
@@ -387,7 +385,6 @@ admin:
probe.failed: true
probe.recovered: true
ssl.expiring: true
- mirror.disconnected: true
retry:
maxAttempts: 5
baseDelayMs: 1000
diff --git a/src/@types/admin-ops.ts b/src/@types/admin-ops.ts
index 69b09d5ff..1c8e19263 100644
--- a/src/@types/admin-ops.ts
+++ b/src/@types/admin-ops.ts
@@ -1,15 +1,3 @@
-export type AdminOpsConnectionState = 'connecting' | 'open' | 'closed'
-
-export interface AdminOpsMirrorHeartbeat {
- index: string
- targetUrl: string
- connectionState: AdminOpsConnectionState
- reconnectCount: number
- lastError?: string
- lastEventAt?: string
- updatedAt: string
-}
-
export interface AdminOpsWorkerHeartbeat {
type: string
index?: string
@@ -19,12 +7,6 @@ export interface AdminOpsWorkerHeartbeat {
updatedAt: string
}
-export interface AdminOpsConfiguredMirror {
- index: string
- targetUrl: string
- enabled: boolean
-}
-
export interface AdminOpsConfiguredWorker {
type: string
index?: string
@@ -32,11 +14,6 @@ export interface AdminOpsConfiguredWorker {
label: string
}
-export interface AdminOpsMirrorRow extends AdminOpsConfiguredMirror {
- heartbeat?: AdminOpsMirrorHeartbeat
- stale: boolean
-}
-
export interface AdminOpsWorkerRow extends AdminOpsConfiguredWorker {
heartbeat?: AdminOpsWorkerHeartbeat
monitored: boolean
diff --git a/src/@types/operator-notifications.ts b/src/@types/operator-notifications.ts
index 4a7debfec..880dfe4f2 100644
--- a/src/@types/operator-notifications.ts
+++ b/src/@types/operator-notifications.ts
@@ -7,7 +7,6 @@ export enum OperatorNotificationEventType {
PROBE_FAILED = 'probe.failed',
PROBE_RECOVERED = 'probe.recovered',
SSL_EXPIRING = 'ssl.expiring',
- MIRROR_DISCONNECTED = 'mirror.disconnected',
}
export type OperatorNotificationChannelType = 'http' | 'discord' | 'slack' | 'telegram'
@@ -32,7 +31,6 @@ export interface OperatorNotificationEventToggles {
'probe.failed'?: boolean
'probe.recovered'?: boolean
'ssl.expiring'?: boolean
- 'mirror.disconnected'?: boolean
}
export interface OperatorNotificationRetrySettings {
diff --git a/src/@types/settings.ts b/src/@types/settings.ts
index 4e91ac58e..26430aea8 100644
--- a/src/@types/settings.ts
+++ b/src/@types/settings.ts
@@ -300,18 +300,11 @@ export interface Remote {
secret: Secret
}
-export interface Mirror {
- address: string
- filters?: SubscriptionFilter[]
- secret?: Secret
- limits?: {
- event?: EventLimits
- }
- skipAdmissionCheck?: boolean
-}
-
+/**
+ * @deprecated The static mirroring worker was removed. Setting `mirroring.static` only logs a warning at start-up.
+ */
export interface Mirroring {
- static?: Mirror[]
+ static?: unknown[]
}
export interface DvmWorker {
diff --git a/src/app/app.ts b/src/app/app.ts
index b3e505eb4..02ba01b9e 100644
--- a/src/app/app.ts
+++ b/src/app/app.ts
@@ -124,16 +124,11 @@ export class App implements IRunnable {
})
logCentered('1 maintenance worker started', width)
- const mirrors = settings?.mirroring?.static
-
- if (Array.isArray(mirrors) && mirrors.length) {
- for (let i = 0; i < mirrors.length; i++) {
- createWorker({
- WORKER_TYPE: 'static-mirroring',
- MIRROR_INDEX: i.toString(),
- })
- }
- logCentered(`${mirrors.length} static-mirroring worker started`, width)
+ if (Array.isArray(settings?.mirroring?.static) && settings.mirroring.static.length) {
+ logger.warn(
+ 'mirroring.static is no longer supported and is ignored; sync with another relay over NIP-77 (negentropy) instead',
+ )
+ logCentered('mirroring.static is no longer supported (ignored)', width)
}
if (settings.nip66?.enabled) {
diff --git a/src/app/static-mirroring-worker.ts b/src/app/static-mirroring-worker.ts
deleted file mode 100644
index 2fc1aa01c..000000000
--- a/src/app/static-mirroring-worker.ts
+++ /dev/null
@@ -1,426 +0,0 @@
-import cluster from 'cluster'
-import { randomUUID } from 'crypto'
-import { anyPass, map, mergeDeepRight, path } from 'ramda'
-import { RawData, WebSocket } from 'ws'
-import { ICacheAdapter } from '../@types/adapters'
-import { AdminOpsConnectionState } from '../@types/admin-ops'
-import { IRunnable } from '../@types/base'
-import { Event, RelayedEvent } from '../@types/event'
-import { OutgoingEventMessage } from '../@types/messages'
-import { IEventRepository, IUserRepository } from '../@types/repositories'
-import { EventLimits, FeeSchedule, Mirror, Settings } from '../@types/settings'
-import { WebSocketServerAdapterEvent } from '../constants/adapter'
-import { EventExpirationTimeMetadataKey } from '../constants/base'
-import { createLogger } from '../factories/logger-factory'
-import { recordMirrorReconnectMetric } from '../telemetry/ops-metrics'
-import { shutdownMetricsTelemetry } from '../telemetry/metrics'
-import { notifyMirrorDisconnected } from '../utils/ops-trust-notifications'
-import {
- ADMIN_OPS_HEARTBEAT_KEEPALIVE_MS,
- publishAdminOpsMirrorHeartbeatSafely,
- publishAdminOpsWorkerHeartbeatSafely,
-} from '../utils/admin-ops-store'
-import {
- getEventExpiration,
- getEventProofOfWork,
- getPubkeyProofOfWork,
- getPublicKey,
- getRelayPrivateKey,
- isDirectMessageEvent,
- isEventIdValid,
- isEventKindOrRangeMatch,
- isEventMatchingFilter,
- isEventSignatureValid,
- isExpiredEvent,
- isFileMessageEvent,
- isSealEvent,
-} from '../utils/event'
-import { createRelayedEventMessage, createSubscriptionMessage } from '../utils/messages'
-
-const logger = createLogger('static-mirror-worker')
-
-export class StaticMirroringWorker implements IRunnable {
- private client: WebSocket | undefined
- private opsKeepaliveInterval: NodeJS.Timeout | undefined
- private config: Mirror
- private connectionState: AdminOpsConnectionState = 'connecting'
- private reconnectCount = 0
- private lastError: string | undefined
- private lastEventAt: string | undefined
- private readonly mirrorIndex: string
-
- public constructor(
- private readonly eventRepository: IEventRepository,
- private readonly userRepository: IUserRepository,
- private readonly process: NodeJS.Process,
- private readonly settings: () => Settings,
- private readonly opsCache?: ICacheAdapter,
- ) {
- this.mirrorIndex = process.env.MIRROR_INDEX ?? '0'
- this.process
- .on('message', this.onMessage.bind(this))
- .on('SIGINT', this.onExit.bind(this))
- .on('SIGHUP', this.onExit.bind(this))
- .on('SIGTERM', this.onExit.bind(this))
- .on('uncaughtException', this.onError.bind(this))
- .on('unhandledRejection', this.onError.bind(this))
- }
-
- private async publishOpsHeartbeats(): Promise
{
- if (!this.opsCache || !this.config) {
- return
- }
-
- const updatedAt = new Date().toISOString()
- await publishAdminOpsMirrorHeartbeatSafely(this.opsCache, {
- index: this.mirrorIndex,
- targetUrl: this.config.address,
- connectionState: this.connectionState,
- reconnectCount: this.reconnectCount,
- lastError: this.lastError,
- lastEventAt: this.lastEventAt,
- updatedAt,
- })
- await publishAdminOpsWorkerHeartbeatSafely(this.opsCache, {
- type: 'static-mirroring',
- index: this.mirrorIndex,
- lastRunFinishedAt: updatedAt,
- updatedAt,
- })
- }
-
- public run(): void {
- const currentSettings = this.settings()
-
- logger.info('mirroring', currentSettings.mirroring)
-
- this.config = path(['mirroring', 'static', process.env.MIRROR_INDEX], currentSettings) as Mirror
-
- let since = Math.floor(Date.now() / 1000) - 60 * 10
-
- void this.publishOpsHeartbeats()
-
- if (this.opsCache) {
- this.opsKeepaliveInterval = setInterval(() => {
- void this.publishOpsHeartbeats()
- }, ADMIN_OPS_HEARTBEAT_KEEPALIVE_MS)
- }
-
- const createMirror = (config: Mirror) => {
- const subscriptionId = `mirror-${randomUUID()}`
-
- logger('connecting to %s', config.address)
- this.connectionState = 'connecting'
- void this.publishOpsHeartbeats()
-
- const client = new WebSocket(config.address, { timeout: 5000 })
-
- client
- .on('open', () => {
- logger('connected to %s', config.address)
- this.connectionState = 'open'
- void this.publishOpsHeartbeats()
-
- if (Array.isArray(config.filters) && config.filters?.length) {
- const filters = config.filters.map((filter) => ({ ...filter, since }))
-
- logger('subscribing with %s: %o', subscriptionId, filters)
-
- client.send(JSON.stringify(createSubscriptionMessage(subscriptionId, filters)))
- }
- })
- .on('message', async (raw: RawData) => {
- try {
- const message = JSON.parse(raw.toString('utf8')) as OutgoingEventMessage
-
- if (!Array.isArray(message)) {
- return
- }
-
- if (message[0] !== 'EVENT' || message[1] !== subscriptionId) {
- logger('%s >> local: %o', config.address, message)
- return
- }
-
- let event = message[2]
-
- if (!anyPass(map(isEventMatchingFilter, config.filters))(event)) {
- return
- }
-
- if (!(await isEventIdValid(event)) || !(await isEventSignatureValid(event))) {
- return
- }
-
- if (isExpiredEvent(event)) {
- return
- }
-
- const eventExpiration = getEventExpiration(event)
- if (eventExpiration) {
- event = {
- ...event,
- [EventExpirationTimeMetadataKey]: eventExpiration,
- } as any
- }
-
- if (!this.canAcceptEvent(event)) {
- return
- }
-
- if (!(await this.isUserAdmitted(event))) {
- return
- }
-
- // NIP-17: inner events (kind 13, 14, 15) must never be stored directly
- if (isSealEvent(event) || isDirectMessageEvent(event) || isFileMessageEvent(event)) {
- return
- }
-
- since = Math.floor(Date.now() / 1000) - 30
- this.lastEventAt = new Date().toISOString()
- void this.publishOpsHeartbeats()
-
- logger('%s >> local: %s', config.address, event.id)
-
- const inserted = await this.eventRepository.create(event)
-
- if (inserted && cluster.isWorker && typeof process.send === 'function') {
- process.send({
- eventName: WebSocketServerAdapterEvent.Broadcast,
- event,
- source: config.address,
- })
- }
- } catch (error) {
- logger('unable to process message: %o', error)
- }
- })
- .on('close', (code, reason) => {
- logger(`disconnected (${code}): ${reason.toString()}`)
- this.connectionState = 'closed'
- this.reconnectCount += 1
- recordMirrorReconnectMetric(this.mirrorIndex)
- void notifyMirrorDisconnected(this.opsCache, {
- index: this.mirrorIndex,
- targetUrl: config.address,
- code,
- reason: reason.toString(),
- })
- void this.publishOpsHeartbeats()
-
- setTimeout(() => {
- this.client.removeAllListeners()
- this.client = createMirror(config)
- }, 5000)
- })
- .on('error', (error) => {
- logger('connection error: %o', error)
- this.lastError = error instanceof Error ? error.message : String(error)
- void this.publishOpsHeartbeats()
- })
-
- return client
- }
-
- this.client = createMirror(this.config)
- }
-
- private getRelayPublicKey(): string {
- const relayPrivkey = getRelayPrivateKey(this.settings().info.relay_url)
- return getPublicKey(relayPrivkey)
- }
-
- private canAcceptEvent(event: Event): boolean {
- if (this.getRelayPublicKey() === event.pubkey) {
- logger(`event ${event.id} not accepted: pubkey is relay pubkey`)
- return false
- }
-
- const now = Math.floor(Date.now() / 1000)
-
- const eventLimits = this.settings().limits?.event ?? {}
-
- const eventLimitOverrides = this.config.limits?.event ?? {}
-
- const limits = mergeDeepRight(eventLimits, eventLimitOverrides) as EventLimits
-
- if (Array.isArray(limits.content)) {
- for (const limit of limits.content) {
- if (
- typeof limit.maxLength !== 'undefined' &&
- limit.maxLength > 0 &&
- event.content.length > limit.maxLength &&
- (!Array.isArray(limit.kinds) || limit.kinds.some(isEventKindOrRangeMatch(event)))
- ) {
- logger(`event ${event.id} not accepted: content is longer than ${limit.maxLength} bytes`)
- return false
- }
- }
- } else if (
- typeof limits.content?.maxLength !== 'undefined' &&
- limits.content?.maxLength > 0 &&
- event.content.length > limits.content.maxLength &&
- (!Array.isArray(limits.content.kinds) || limits.content.kinds.some(isEventKindOrRangeMatch(event)))
- ) {
- logger(`event ${event.id} not accepted: content is longer than ${limits.content.maxLength} bytes`)
- return false
- }
-
- if (
- typeof limits.createdAt?.maxPositiveDelta !== 'undefined' &&
- limits.createdAt.maxPositiveDelta > 0 &&
- event.created_at > now + limits.createdAt.maxPositiveDelta
- ) {
- logger(
- `event ${event.id} not accepted: created_at is more than ${limits.createdAt.maxPositiveDelta} seconds in the future`,
- )
- return false
- }
-
- if (
- typeof limits.createdAt?.maxNegativeDelta !== 'undefined' &&
- limits.createdAt.maxNegativeDelta > 0 &&
- event.created_at < now - limits.createdAt.maxNegativeDelta
- ) {
- logger(
- `event ${event.id} not accepted: created_at is more than ${limits.createdAt.maxNegativeDelta} seconds in the past`,
- )
- return false
- }
-
- if (typeof limits.eventId?.minLeadingZeroBits !== 'undefined' && limits.eventId.minLeadingZeroBits > 0) {
- const pow = getEventProofOfWork(event.id)
- if (pow < limits.eventId.minLeadingZeroBits) {
- logger(`event ${event.id} not accepted: pow difficulty ${pow}<${limits.eventId.minLeadingZeroBits}`)
- return false
- }
- }
-
- if (typeof limits.pubkey?.minLeadingZeroBits !== 'undefined' && limits.pubkey.minLeadingZeroBits > 0) {
- const pow = getPubkeyProofOfWork(event.pubkey)
- if (pow < limits.pubkey.minLeadingZeroBits) {
- logger(`event ${event.id} not accepted: pow pubkey difficulty ${pow}<${limits.pubkey.minLeadingZeroBits}`)
- return false
- }
- }
-
- if (
- typeof limits.pubkey?.whitelist !== 'undefined' &&
- limits.pubkey.whitelist.length > 0 &&
- !limits.pubkey.whitelist.includes(event.pubkey)
- ) {
- logger(`event ${event.id} not accepted: pubkey not allowed: ${event.pubkey}`)
- return false
- }
-
- if (
- typeof limits.pubkey?.blacklist !== 'undefined' &&
- limits.pubkey.blacklist.length > 0 &&
- limits.pubkey.blacklist.includes(event.pubkey)
- ) {
- logger(`event ${event.id} not accepted: pubkey not allowed: ${event.pubkey}`)
- return false
- }
-
- if (
- typeof limits.kind?.whitelist !== 'undefined' &&
- limits.kind.whitelist.length > 0 &&
- !limits.kind.whitelist.some(isEventKindOrRangeMatch(event))
- ) {
- logger(`blocked: event kind ${event.kind} not allowed`)
- return false
- }
-
- if (
- typeof limits.kind?.blacklist !== 'undefined' &&
- limits.kind.blacklist.length > 0 &&
- limits.kind.blacklist.some(isEventKindOrRangeMatch(event))
- ) {
- logger(`blocked: event kind ${event.kind} not allowed`)
- return false
- }
-
- return true
- }
-
- protected async isUserAdmitted(event: Event): Promise {
- const currentSettings = this.settings()
-
- if (this.config.skipAdmissionCheck === true) {
- return true
- }
-
- if (currentSettings.payments?.enabled !== true) {
- return true
- }
-
- const isApplicableFee = (feeSchedule: FeeSchedule) =>
- feeSchedule.enabled &&
- !feeSchedule.whitelists?.pubkeys?.includes(event.pubkey) &&
- !feeSchedule.whitelists?.event_kinds?.some(isEventKindOrRangeMatch(event))
-
- const feeSchedules = currentSettings.payments?.feeSchedules?.admission?.filter(isApplicableFee)
-
- if (!Array.isArray(feeSchedules) || !feeSchedules.length) {
- return true
- }
-
- const user = await this.userRepository.findByPubkey(event.pubkey)
- if (user?.isAdmitted !== true) {
- logger(`user not admitted: ${event.pubkey}`)
- return false
- }
-
- const minBalance = currentSettings.limits?.event?.pubkey?.minBalance
- if (minBalance && user.balance < minBalance) {
- logger(`user not admitted: user balance ${user.balance} < ${minBalance}`)
- return false
- }
-
- return true
- }
-
- private onMessage(message: { eventName: string; event: unknown; source: string }): void {
- if (
- message.eventName !== WebSocketServerAdapterEvent.Broadcast ||
- message.source === this.config.address ||
- !this.client ||
- this.client.readyState !== WebSocket.OPEN
- ) {
- return
- }
-
- const event = message.event as RelayedEvent
-
- const eventToRelay = createRelayedEventMessage(event, this.config.secret)
- const outboundMessage = JSON.stringify(eventToRelay)
- logger('%s >> %s: %s', message.source ?? 'local', this.config.address, outboundMessage)
- this.client.send(outboundMessage)
- }
-
- private onError(error: Error) {
- logger('error: %o', error)
- throw error
- }
-
- private onExit() {
- logger('exiting')
- void shutdownMetricsTelemetry().finally(() => {
- this.close(() => {
- this.process.exit(0)
- })
- })
- }
-
- public close(callback?: () => void) {
- logger('closing')
- clearInterval(this.opsKeepaliveInterval)
- if (this.client) {
- this.client.terminate()
- }
- if (typeof callback === 'function') {
- callback()
- }
- }
-}
diff --git a/src/controllers/admin/get-ops-mirrors-controller.ts b/src/controllers/admin/get-ops-mirrors-controller.ts
deleted file mode 100644
index be81339c7..000000000
--- a/src/controllers/admin/get-ops-mirrors-controller.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Request, Response } from 'express'
-
-import { IController } from '../../@types/controllers'
-import { Settings } from '../../@types/settings'
-import { AdminOpsStore } from '../../utils/admin-ops-store'
-import { listConfiguredMirrors, mergeMirrorRows } from '../../utils/admin-ops-config'
-import { loadMergedSettings } from '../../utils/settings-config'
-
-export class GetAdminOpsMirrorsController implements IController {
- public constructor(
- private readonly opsStore: AdminOpsStore,
- private readonly getSettings: () => Settings = loadMergedSettings,
- ) {}
-
- public async handleRequest(_request: Request, response: Response): Promise {
- const [heartbeats, settings] = await Promise.all([this.opsStore.listMirrorHeartbeats(), this.getSettings()])
- const mirrors = mergeMirrorRows(listConfiguredMirrors(settings), heartbeats)
-
- response.status(200).setHeader('content-type', 'application/json').send({ mirrors })
- }
-}
diff --git a/src/factories/controllers/get-admin-ops-mirrors-controller-factory.ts b/src/factories/controllers/get-admin-ops-mirrors-controller-factory.ts
deleted file mode 100644
index 04af6ced3..000000000
--- a/src/factories/controllers/get-admin-ops-mirrors-controller-factory.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { RedisAdapter } from '../../adapters/redis-adapter'
-import { IController } from '../../@types/controllers'
-import { getCacheClient } from '../../cache/client'
-import { GetAdminOpsMirrorsController } from '../../controllers/admin/get-ops-mirrors-controller'
-import { AdminOpsStore } from '../../utils/admin-ops-store'
-
-export const createGetAdminOpsMirrorsController = (): IController => {
- const opsStore = new AdminOpsStore(new RedisAdapter(getCacheClient()))
- return new GetAdminOpsMirrorsController(opsStore)
-}
diff --git a/src/factories/static-mirroring.worker-factory.ts b/src/factories/static-mirroring.worker-factory.ts
deleted file mode 100644
index e2420ac0f..000000000
--- a/src/factories/static-mirroring.worker-factory.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { getMasterDbClient, getReadReplicaDbClient } from '../database/client'
-import { createAdminOpsCacheAdapter } from './admin-ops-cache-adapter-factory'
-import { createEventRepository } from './event-repository-factory'
-import { createSettings } from './settings-factory'
-import { StaticMirroringWorker } from '../app/static-mirroring-worker'
-import { UserRepository } from '../repositories/user-repository'
-
-export const staticMirroringWorkerFactory = () => {
- const dbClient = getMasterDbClient()
- const readReplicaDbClient = getReadReplicaDbClient()
- const eventRepository = createEventRepository(dbClient, readReplicaDbClient)
- const userRepository = new UserRepository(dbClient, eventRepository)
- return new StaticMirroringWorker(
- eventRepository,
- userRepository,
- process,
- createSettings,
- createAdminOpsCacheAdapter(),
- )
-}
diff --git a/src/index.ts b/src/index.ts
index a1f9d1a99..b909447ca 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -4,7 +4,6 @@ import { appFactory } from './factories/app-factory'
import { dvmOrchestratorWorkerFactory } from './factories/dvm-orchestrator-worker-factory'
import { maintenanceWorkerFactory } from './factories/maintenance-worker-factory'
import { relayMonitorWorkerFactory } from './factories/relay-monitor-worker-factory'
-import { staticMirroringWorkerFactory } from './factories/static-mirroring.worker-factory'
import { workerFactory } from './factories/worker-factory'
import { initializeMetricsTelemetry } from './telemetry/metrics'
@@ -17,8 +16,6 @@ export const getRunner = () => {
return workerFactory()
case 'maintenance':
return maintenanceWorkerFactory()
- case 'static-mirroring':
- return staticMirroringWorkerFactory()
case 'relay-monitor':
return relayMonitorWorkerFactory()
case 'dvm-orchestrator':
diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts
index 97269f9fc..fe26133e7 100644
--- a/src/routes/admin/index.ts
+++ b/src/routes/admin/index.ts
@@ -17,7 +17,6 @@ import { createPostAdminLoginController } from '../../factories/controllers/post
import { createPostAdminLogoutController } from '../../factories/controllers/post-admin-logout-controller-factory'
import { createPostAdminSettingsRestoreController } from '../../factories/controllers/post-admin-settings-restore-controller-factory'
import { createPostAdminSettingsValidateController } from '../../factories/controllers/post-admin-settings-validate-controller-factory'
-import { createGetAdminOpsMirrorsController } from '../../factories/controllers/get-admin-ops-mirrors-controller-factory'
import { createGetAdminOpsWorkersController } from '../../factories/controllers/get-admin-ops-workers-controller-factory'
import { createGetAdminOpsNip05VerificationsController } from '../../factories/controllers/get-admin-ops-nip05-verifications-controller-factory'
import { createGetAdminOpsStorageCountsController } from '../../factories/controllers/get-admin-ops-storage-counts-controller-factory'
@@ -169,12 +168,6 @@ router.post(
adminAuthMiddleware,
withAdminController(createPostAdminNotificationTestController),
)
-router.get(
- '/ops/mirrors',
- adminRateLimitMiddleware,
- adminAuthMiddleware,
- withAdminController(createGetAdminOpsMirrorsController),
-)
router.get(
'/ops/workers',
adminRateLimitMiddleware,
diff --git a/src/schemas/admin-notifications-schema.ts b/src/schemas/admin-notifications-schema.ts
index 0cb4f5c7d..bba837296 100644
--- a/src/schemas/admin-notifications-schema.ts
+++ b/src/schemas/admin-notifications-schema.ts
@@ -26,7 +26,6 @@ export const adminNotificationsPatchBodySchema = z
'probe.failed': z.boolean().optional(),
'probe.recovered': z.boolean().optional(),
'ssl.expiring': z.boolean().optional(),
- 'mirror.disconnected': z.boolean().optional(),
})
.strict()
.optional(),
diff --git a/src/telemetry/ops-metrics.ts b/src/telemetry/ops-metrics.ts
index 27f8eaafd..c4fb68a31 100644
--- a/src/telemetry/ops-metrics.ts
+++ b/src/telemetry/ops-metrics.ts
@@ -8,7 +8,6 @@ const logger = createLogger('telemetry-ops-metrics')
export interface OpsMetricInstruments {
probeRunsTotal: Counter
probeRunDurationMs: Histogram
- mirrorReconnectsTotal: Counter
}
let opsMetricInstruments: OpsMetricInstruments | undefined
@@ -24,9 +23,6 @@ const createOpsMetricInstruments = (): OpsMetricInstruments => {
description: 'Wall-clock duration of a probe run in milliseconds',
unit: 'ms',
}),
- mirrorReconnectsTotal: meter.createCounter('nostream.mirror.reconnects_total', {
- description: 'Static mirror websocket reconnect attempts',
- }),
}
}
@@ -49,8 +45,3 @@ export const recordProbeRunMetrics = (status: string, durationMs: number): void
instruments.probeRunsTotal.add(1, attributes)
instruments.probeRunDurationMs.record(durationMs, attributes)
}
-
-export const recordMirrorReconnectMetric = (mirrorIndex: string): void => {
- const instruments = getOpsMetricInstruments()
- instruments.mirrorReconnectsTotal.add(1, { ...getWorkerAttributes(), mirror_index: mirrorIndex })
-}
diff --git a/src/utils/admin-diagnostics-export.ts b/src/utils/admin-diagnostics-export.ts
index edf63809c..0511eb3a1 100644
--- a/src/utils/admin-diagnostics-export.ts
+++ b/src/utils/admin-diagnostics-export.ts
@@ -22,7 +22,6 @@ export interface AdminDiagnosticsExport {
}
operations: {
workers: Awaited>
- mirrors: Awaited>
}
}
@@ -43,10 +42,9 @@ export const buildAdminDiagnosticsExport = async (
const opsStore = new AdminOpsStore(cache)
const snapshotStore = new RelayProbeSnapshotStore(cache)
- const [health, workers, mirrors, latest] = await Promise.all([
+ const [health, workers, latest] = await Promise.all([
collectAdminHealthSnapshot(),
opsStore.listWorkerHeartbeats(),
- opsStore.listMirrorHeartbeats(),
snapshotStore.getLatest(),
])
@@ -59,6 +57,6 @@ export const buildAdminDiagnosticsExport = async (
settings,
notifications: getRedactedAdminNotifications(),
networkHealth: { latest },
- operations: { workers, mirrors },
+ operations: { workers },
}
}
diff --git a/src/utils/admin-ops-config.ts b/src/utils/admin-ops-config.ts
index 4e5d645db..6fdaee741 100644
--- a/src/utils/admin-ops-config.ts
+++ b/src/utils/admin-ops-config.ts
@@ -1,13 +1,6 @@
import { cpus } from 'os'
-import {
- AdminOpsConfiguredMirror,
- AdminOpsConfiguredWorker,
- AdminOpsMirrorHeartbeat,
- AdminOpsMirrorRow,
- AdminOpsWorkerHeartbeat,
- AdminOpsWorkerRow,
-} from '../@types/admin-ops'
+import { AdminOpsConfiguredWorker, AdminOpsWorkerHeartbeat, AdminOpsWorkerRow } from '../@types/admin-ops'
import { Settings } from '../@types/settings'
import { ADMIN_OPS_HEARTBEAT_TTL_SECONDS, isAdminOpsHeartbeatStale } from './admin-ops-store'
@@ -15,19 +8,6 @@ const UNMONITORED_WORKER_TYPES = new Set(['worker', 'dvm-orchestrator'])
export const isAdminOpsWorkerMonitored = (type: string): boolean => !UNMONITORED_WORKER_TYPES.has(type)
-export const listConfiguredMirrors = (settings: Settings): AdminOpsConfiguredMirror[] => {
- const mirrors = settings.mirroring?.static
- if (!Array.isArray(mirrors) || !mirrors.length) {
- return []
- }
-
- return mirrors.map((mirror, index) => ({
- index: index.toString(),
- targetUrl: mirror.address,
- enabled: true,
- }))
-}
-
export const listConfiguredWorkers = (settings: Settings): AdminOpsConfiguredWorker[] => {
const workers: AdminOpsConfiguredWorker[] = []
@@ -50,16 +30,6 @@ export const listConfiguredWorkers = (settings: Settings): AdminOpsConfiguredWor
label: 'Maintenance',
})
- const mirrors = listConfiguredMirrors(settings)
- for (const mirror of mirrors) {
- workers.push({
- type: 'static-mirroring',
- index: mirror.index,
- enabled: mirror.enabled,
- label: `Static mirror ${mirror.index}`,
- })
- }
-
if (settings.nip66?.enabled) {
workers.push({
type: 'relay-monitor',
@@ -86,22 +56,6 @@ export const listConfiguredWorkers = (settings: Settings): AdminOpsConfiguredWor
const heartbeatKey = (type: string, index?: string) =>
typeof index === 'string' && index.length > 0 ? `${type}:${index}` : type
-export const mergeMirrorRows = (
- configured: AdminOpsConfiguredMirror[],
- heartbeats: AdminOpsMirrorHeartbeat[],
-): AdminOpsMirrorRow[] => {
- const byIndex = new Map(heartbeats.map((heartbeat) => [heartbeat.index, heartbeat]))
-
- return configured.map((mirror) => {
- const heartbeat = byIndex.get(mirror.index)
- return {
- ...mirror,
- heartbeat,
- stale: !heartbeat || isAdminOpsHeartbeatStale(heartbeat.updatedAt, ADMIN_OPS_HEARTBEAT_TTL_SECONDS),
- }
- })
-}
-
export const mergeWorkerRows = (
configured: AdminOpsConfiguredWorker[],
heartbeats: AdminOpsWorkerHeartbeat[],
diff --git a/src/utils/admin-ops-store.ts b/src/utils/admin-ops-store.ts
index 00279c352..7bf931fe8 100644
--- a/src/utils/admin-ops-store.ts
+++ b/src/utils/admin-ops-store.ts
@@ -1,5 +1,5 @@
import { ICacheAdapter } from '../@types/adapters'
-import { AdminOpsMirrorHeartbeat, AdminOpsWorkerHeartbeat } from '../@types/admin-ops'
+import { AdminOpsWorkerHeartbeat } from '../@types/admin-ops'
import { createLogger } from '../factories/logger-factory'
import { sanitizeAdminOpsError } from './admin-ops-sanitize'
@@ -9,23 +9,14 @@ export const ADMIN_OPS_HEARTBEAT_INDEX_KEY = 'admin:ops:heartbeat:index'
export const ADMIN_OPS_HEARTBEAT_TTL_SECONDS = 120
export const ADMIN_OPS_HEARTBEAT_KEEPALIVE_MS = 45_000
-const mirrorKey = (index: string) => `admin:ops:mirror:${index}`
const workerKey = (type: string, index?: string) =>
typeof index === 'string' && index.length > 0 ? `admin:ops:worker:${type}:${index}` : `admin:ops:worker:${type}`
-const indexMember = (category: 'mirror' | 'worker', id: string) => `${category}:${id}`
+const indexMember = (category: 'worker', id: string) => `${category}:${id}`
export class AdminOpsStore {
public constructor(private readonly cache: ICacheAdapter) {}
- public async publishMirror(state: AdminOpsMirrorHeartbeat): Promise {
- const key = mirrorKey(state.index)
- const member = indexMember('mirror', state.index)
- await this.cache.setKey(key, JSON.stringify(state), ADMIN_OPS_HEARTBEAT_TTL_SECONDS)
- await this.cache.addToSet(ADMIN_OPS_HEARTBEAT_INDEX_KEY, [member])
- await this.cache.setKeyExpiry(ADMIN_OPS_HEARTBEAT_INDEX_KEY, ADMIN_OPS_HEARTBEAT_TTL_SECONDS * 4)
- }
-
public async publishWorker(state: AdminOpsWorkerHeartbeat): Promise {
const key = workerKey(state.type, state.index)
const member = indexMember('worker', typeof state.index === 'string' ? `${state.type}:${state.index}` : state.type)
@@ -34,27 +25,6 @@ export class AdminOpsStore {
await this.cache.setKeyExpiry(ADMIN_OPS_HEARTBEAT_INDEX_KEY, ADMIN_OPS_HEARTBEAT_TTL_SECONDS * 4)
}
- public async listMirrorHeartbeats(): Promise {
- const members = await this.cache.getSetMembers(ADMIN_OPS_HEARTBEAT_INDEX_KEY)
- const mirrorMembers = members.filter((member) => member.startsWith('mirror:'))
-
- const heartbeats: AdminOpsMirrorHeartbeat[] = []
- for (const member of mirrorMembers) {
- const index = member.slice('mirror:'.length)
- const raw = await this.cache.getKey(mirrorKey(index))
- if (!raw) {
- continue
- }
- try {
- heartbeats.push(JSON.parse(raw) as AdminOpsMirrorHeartbeat)
- } catch {
- // ignore corrupt payloads
- }
- }
-
- return heartbeats.sort((a, b) => a.index.localeCompare(b.index, undefined, { numeric: true }))
- }
-
public async listWorkerHeartbeats(): Promise {
const members = await this.cache.getSetMembers(ADMIN_OPS_HEARTBEAT_INDEX_KEY)
const workerMembers = members.filter((member) => member.startsWith('worker:'))
@@ -102,23 +72,11 @@ export const resetAdminOpsStore = (): void => {
sharedStoreCache = undefined
}
-const sanitizeMirrorHeartbeat = (state: AdminOpsMirrorHeartbeat): AdminOpsMirrorHeartbeat => ({
- ...state,
- lastError: state.lastError !== undefined ? sanitizeAdminOpsError(state.lastError) : undefined,
-})
-
const sanitizeWorkerHeartbeat = (state: AdminOpsWorkerHeartbeat): AdminOpsWorkerHeartbeat => ({
...state,
lastError: state.lastError !== undefined ? sanitizeAdminOpsError(state.lastError) : undefined,
})
-export const publishAdminOpsMirrorHeartbeat = async (
- cache: ICacheAdapter,
- state: AdminOpsMirrorHeartbeat,
-): Promise => {
- await getAdminOpsStore(cache).publishMirror(sanitizeMirrorHeartbeat(state))
-}
-
export const publishAdminOpsWorkerHeartbeat = async (
cache: ICacheAdapter,
state: AdminOpsWorkerHeartbeat,
@@ -126,20 +84,6 @@ export const publishAdminOpsWorkerHeartbeat = async (
await getAdminOpsStore(cache).publishWorker(sanitizeWorkerHeartbeat(state))
}
-export const publishAdminOpsMirrorHeartbeatSafely = async (
- cache: ICacheAdapter | undefined,
- state: AdminOpsMirrorHeartbeat,
-): Promise => {
- if (!cache) {
- return
- }
- try {
- await publishAdminOpsMirrorHeartbeat(cache, state)
- } catch (error) {
- logger.warn('failed to publish mirror ops heartbeat: %o', error)
- }
-}
-
export const publishAdminOpsWorkerHeartbeatSafely = async (
cache: ICacheAdapter | undefined,
state: AdminOpsWorkerHeartbeat,
diff --git a/src/utils/ops-trust-notifications.ts b/src/utils/ops-trust-notifications.ts
index 633b587c4..313601464 100644
--- a/src/utils/ops-trust-notifications.ts
+++ b/src/utils/ops-trust-notifications.ts
@@ -8,7 +8,6 @@ const logger = createLogger('ops-trust-notifications')
const PROBE_STATUS_KEY = 'admin:trust:probe:last_status'
const SSL_ALERT_PREFIX = 'admin:trust:ssl:alert:'
-const MIRROR_DISCONNECT_PREFIX = 'admin:trust:mirror:disconnect:'
const SSL_EXPIRY_WARNING_DAYS = 14
const DEDUPE_TTL_SECONDS = 6 * 3600
@@ -150,24 +149,3 @@ const notifySslExpiringFromSnapshot = async (
}
}
}
-
-export const notifyMirrorDisconnected = async (
- cache: ICacheAdapter | undefined,
- fields: { index: string; targetUrl: string; code?: number; reason?: string },
-): Promise => {
- if (!cache) {
- return
- }
-
- const dedupeKey = `${MIRROR_DISCONNECT_PREFIX}${fields.index}`
- try {
- await sendDedupedTrustNotification(cache, dedupeKey, OperatorNotificationEventType.MIRROR_DISCONNECTED, {
- index: fields.index,
- targetUrl: fields.targetUrl,
- code: fields.code,
- reason: fields.reason,
- })
- } catch (error) {
- logger.error('mirror disconnected notification failed: %o', error)
- }
-}
diff --git a/src/utils/relay-probe-targets.ts b/src/utils/relay-probe-targets.ts
index 9212533d0..66de9898e 100644
--- a/src/utils/relay-probe-targets.ts
+++ b/src/utils/relay-probe-targets.ts
@@ -25,7 +25,7 @@ const addUniqueTarget = (targets: string[], seen: Set, candidate: string
targets.push(trimmed)
}
-/** Targets eligible for public kind 30166 publish (never includes mirroring.static peers). */
+/** Targets eligible for public kind 30166 publish. */
export const resolvePublicProbeTargets = (settings: Settings): string[] => {
const targets: string[] = []
const seen = new Set()
@@ -56,25 +56,8 @@ export const resolvePublicProbeTargetKeys = (settings: Settings): Set =>
return keys
}
-/** All probe targets: public targets plus configured static mirrors (operator snapshot only). */
-export const resolveProbeTargets = (settings: Settings): string[] => {
- const targets = resolvePublicProbeTargets(settings)
- const seen = new Set()
-
- for (const target of targets) {
- try {
- seen.add(normalizeRelayUrlForDTag(target))
- } catch {
- seen.add(target.toLowerCase())
- }
- }
-
- for (const mirror of settings.mirroring?.static ?? []) {
- addUniqueTarget(targets, seen, mirror.address)
- }
-
- return targets
-}
+/** All probe targets. */
+export const resolveProbeTargets = (settings: Settings): string[] => resolvePublicProbeTargets(settings)
export const filterValidProbeTargets = (targets: string[]): { valid: string[]; invalid: string[] } => {
const valid: string[] = []
diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts
index 5ac0cbee0..de7e94a9a 100644
--- a/src/utils/settings-config.ts
+++ b/src/utils/settings-config.ts
@@ -62,6 +62,12 @@ const assertSafePathKey = (key: string, segment: string): void => {
}
}
+/** Settings that no longer do anything because the static mirroring worker was removed. */
+const REMOVED_SETTINGS = [
+ { path: '$', key: 'mirroring' },
+ { path: 'admin.notifications.events', key: 'mirror.disconnected' },
+]
+
const hasOwn = (object: Record, key: string): boolean => {
return Object.prototype.hasOwnProperty.call(object, key)
}
@@ -235,6 +241,11 @@ const validateShape = (schema: unknown, candidate: unknown, path: PathToken[], i
continue
}
+ // Settings that were removed stay accepted, so a settings file that still has one can be saved.
+ if (REMOVED_SETTINGS.some((removed) => removed.path === renderedPath && removed.key === key)) {
+ continue
+ }
+
if (!hasOwn(schema, key)) {
issues.push({
path: formatPathTokens([...path, { type: 'key', key }]),
diff --git a/test/unit/app/app.spec.ts b/test/unit/app/app.spec.ts
index 356312ded..43586ab20 100644
--- a/test/unit/app/app.spec.ts
+++ b/test/unit/app/app.spec.ts
@@ -178,4 +178,30 @@ describe('App', () => {
expect(cluster.fork).to.have.been.calledThrice
expect(cluster.fork.thirdCall.args[0]).to.deep.equal(cluster.fork.firstCall.args[0])
})
+ it('does not start a mirroring worker for mirroring.static, which is no longer supported', () => {
+ const env = { ...process.env }
+ delete env.WORKER_COUNT
+ delete env.RELAY_BROADCAST_FANOUT
+ sandbox.stub(process, 'env').value(env)
+ sandbox.useFakeTimers()
+ sandbox.stub(SettingsStatic, 'watchSettings').returns([])
+ sandbox.stub(operatorNotificationEnqueue, 'enqueueOperatorNotification').resolves()
+ sandbox.stub(torClient, 'addOnion').rejects(new Error('tor is not configured'))
+ let pid = 1001
+ cluster.fork.callsFake(() => createWorker(String(pid), pid++))
+ const app = new App(
+ fakeProcess as any,
+ cluster as any,
+ sandbox.stub().returns({
+ payments: { enabled: false },
+ workers: { count: 1 },
+ eventStore: { backend: 'postgres' },
+ mirroring: { static: [{ address: 'wss://mirror.example' }, { address: 'wss://other.example' }] },
+ } as unknown as Settings),
+ )
+
+ app.run()
+
+ expect(cluster.fork.getCalls().map((call) => call.args[0].WORKER_TYPE)).to.deep.equal(['worker', 'maintenance'])
+ })
})
diff --git a/test/unit/routes/admin-settings.spec.ts b/test/unit/routes/admin-settings.spec.ts
index 2d4f1f085..fdf483cd1 100644
--- a/test/unit/routes/admin-settings.spec.ts
+++ b/test/unit/routes/admin-settings.spec.ts
@@ -177,9 +177,14 @@ describe('admin settings API', () => {
const passwordHash = hashAdminPassword('settings-password')
saveSettings({
...loadDefaults(),
- admin: { enabled: true, passwordHash, sessionTtlSeconds: 3600 },
- mirroring: {
- static: [{ address: 'wss://mirror.example', secret: 'mirror-secret-value' }],
+ admin: {
+ enabled: true,
+ passwordHash,
+ sessionTtlSeconds: 3600,
+ notifications: {
+ ...loadDefaults().admin?.notifications,
+ targets: [{ id: 'ops-telegram', type: 'telegram', enabled: true, botToken: 'telegram-bot-token', chatId: '1' }],
+ },
},
})
const baseUrl = await startServer({
@@ -198,7 +203,7 @@ describe('admin settings API', () => {
expect(settingsResponse.status).to.equal(200)
expect(settingsResponse.data.settings.admin.passwordHash).to.equal('***')
- expect(settingsResponse.data.settings.mirroring.static[0].secret).to.equal('***')
+ expect(settingsResponse.data.settings.admin.notifications.targets[0].botToken).to.equal('***')
expect(settingsResponse.data.settings.info.name).to.be.a('string')
expect(schemaResponse.status).to.equal(200)
diff --git a/test/unit/utils/admin-ops-config.spec.ts b/test/unit/utils/admin-ops-config.spec.ts
index 3a1915dbb..7694db13f 100644
--- a/test/unit/utils/admin-ops-config.spec.ts
+++ b/test/unit/utils/admin-ops-config.spec.ts
@@ -1,6 +1,7 @@
import { expect } from 'chai'
-import { mergeMirrorRows, mergeWorkerRows } from '../../../src/utils/admin-ops-config'
+import { listConfiguredWorkers, mergeWorkerRows } from '../../../src/utils/admin-ops-config'
+import { Settings } from '../../../src/@types/settings'
import { ADMIN_OPS_HEARTBEAT_TTL_SECONDS, isAdminOpsHeartbeatStale } from '../../../src/utils/admin-ops-store'
describe('admin-ops-config', () => {
@@ -25,19 +26,18 @@ describe('admin-ops-config', () => {
expect(rows[0].stale).to.equal(true)
})
- it('merges mirror heartbeats and stale state', () => {
+ it('decides when a heartbeat is stale from the time it was last updated', () => {
const freshUpdatedAt = new Date().toISOString()
- const rows = mergeMirrorRows([{ index: '0', targetUrl: 'wss://mirror', enabled: true }], [
- {
- index: '0',
- targetUrl: 'wss://mirror',
- connectionState: 'open',
- reconnectCount: 0,
- updatedAt: freshUpdatedAt,
- },
- ])
- expect(rows[0].stale).to.equal(false)
expect(isAdminOpsHeartbeatStale(freshUpdatedAt, ADMIN_OPS_HEARTBEAT_TTL_SECONDS)).to.equal(false)
})
+
+ it('no longer lists mirroring workers, even for a mirroring.static that is still configured', () => {
+ const workers = listConfiguredWorkers({
+ workers: { count: 1 },
+ mirroring: { static: [{ address: 'wss://mirror.example' }] },
+ } as unknown as Settings)
+
+ expect(workers.map((worker) => worker.type)).to.deep.equal(['worker', 'maintenance'])
+ })
})
diff --git a/test/unit/utils/ops-trust-notifications.spec.ts b/test/unit/utils/ops-trust-notifications.spec.ts
index 22e56a762..f22af3858 100644
--- a/test/unit/utils/ops-trust-notifications.spec.ts
+++ b/test/unit/utils/ops-trust-notifications.spec.ts
@@ -3,7 +3,7 @@ import Sinon from 'sinon'
import { ICacheAdapter } from '../../../src/@types/adapters'
import { OperatorNotificationEventType } from '../../../src/@types/operator-notifications'
-import { notifyMirrorDisconnected, notifyProbeRunTransition } from '../../../src/utils/ops-trust-notifications'
+import { notifyProbeRunTransition } from '../../../src/utils/ops-trust-notifications'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const enqueueModule = require('../../../src/utils/operator-notification-enqueue')
@@ -34,23 +34,6 @@ describe('ops-trust-notifications', () => {
Sinon.restore()
})
- it('does not mark mirror dedupe key when enqueue fails', async () => {
- const deleteKey = Sinon.stub().resolves(1)
- const setKey = Sinon.stub().resolves(true)
- const cache = createCacheStub({
- setKeyIfNotExists: async () => true,
- deleteKey,
- setKey,
- })
-
- Sinon.stub(enqueueModule, 'enqueueOperatorNotification').resolves(false)
-
- await notifyMirrorDisconnected(cache, { index: '0', targetUrl: 'wss://mirror.example' })
-
- expect(deleteKey.called).to.equal(true)
- expect(setKey.called).to.equal(false)
- })
-
it('enqueues probe failed alerts from atomic transition action', async () => {
const enqueue = Sinon.stub(enqueueModule, 'enqueueOperatorNotification').resolves(true)
const cache = createCacheStub({
diff --git a/test/unit/utils/relay-probe-targets.spec.ts b/test/unit/utils/relay-probe-targets.spec.ts
index 4fc27caa2..63bea6295 100644
--- a/test/unit/utils/relay-probe-targets.spec.ts
+++ b/test/unit/utils/relay-probe-targets.spec.ts
@@ -42,44 +42,16 @@ describe('relay-probe-targets', () => {
expect(resolveProbeTargets(settings)).to.deep.equal(['wss://one.example', 'wss://two.example'])
})
- it('does not include mirroring.static in public publish targets', () => {
+ it('does not probe mirroring.static addresses, which are no longer supported', () => {
const settings = {
...baseSettings,
mirroring: {
static: [{ address: 'wss://mirror.example' }],
},
- } as Settings
+ } as unknown as Settings
expect(resolvePublicProbeTargets(settings)).to.deep.equal(['wss://relay.example.com'])
- })
-
- it('includes mirroring.static addresses in addition to configured targets', () => {
- const settings = {
- ...baseSettings,
- nip66: {
- ...baseSettings.nip66!,
- targets: ['wss://one.example'],
- },
- mirroring: {
- static: [{ address: 'wss://mirror.example' }, { address: 'wss://one.example' }],
- },
- } as Settings
-
- expect(resolveProbeTargets(settings)).to.deep.equal(['wss://one.example', 'wss://mirror.example'])
- })
-
- it('includes mirror targets when falling back to info.relay_url', () => {
- const settings = {
- ...baseSettings,
- mirroring: {
- static: [{ address: 'wss://mirror.example' }],
- },
- } as Settings
-
- expect(resolveProbeTargets(settings)).to.deep.equal([
- 'wss://relay.example.com',
- 'wss://mirror.example',
- ])
+ expect(resolveProbeTargets(settings)).to.deep.equal(['wss://relay.example.com'])
})
it('filters invalid probe targets', () => {
diff --git a/test/unit/utils/settings-config.spec.ts b/test/unit/utils/settings-config.spec.ts
index 407385e2a..2d7403e6e 100644
--- a/test/unit/utils/settings-config.spec.ts
+++ b/test/unit/utils/settings-config.spec.ts
@@ -538,6 +538,39 @@ describe('settings-config', () => {
})
})
+ describe('removed settings', () => {
+ const settingsWith = (extra: Record) =>
+ ({ ...loadDefaults(), info: { relay_url: 'wss://relay.example.com', name: 'test' }, ...extra }) as any
+
+ it('accepts a mirroring.static that is still configured, so an unrelated change can be saved', () => {
+ const settings = settingsWith({ mirroring: { static: [{ address: 'wss://mirror.example', secret: 'x' }] } })
+ settings.info.name = 'a new name'
+
+ expect(validateSettings(settings)).to.deep.equal([])
+ })
+
+ it('accepts the mirror.disconnected notification toggle that is still configured', () => {
+ const settings = settingsWith({})
+ settings.admin = {
+ ...settings.admin,
+ notifications: { ...settings.admin?.notifications, events: { 'mirror.disconnected': true } },
+ }
+
+ expect(validateSettings(settings)).to.deep.equal([])
+ })
+
+ it('still rejects an unknown key at the top level', () => {
+ expect(validateSettings(settingsWith({ nonsense: true })).map((issue) => issue.path)).to.deep.equal(['nonsense'])
+ })
+
+ it('still rejects an unknown key inside a known section', () => {
+ const settings = settingsWith({})
+ settings.limits = { ...settings.limits, nonsense: true }
+
+ expect(validateSettings(settings).map((issue) => issue.path)).to.include('limits.nonsense')
+ })
+ })
+
describe('event store settings', () => {
const baseSettings = (): any => ({
info: { relay_url: 'wss://test.relay', name: 'test' },