diff --git a/.changeset/sunset-static-mirroring-worker.md b/.changeset/sunset-static-mirroring-worker.md new file mode 100644 index 000000000..eea67e290 --- /dev/null +++ b/.changeset/sunset-static-mirroring-worker.md @@ -0,0 +1,7 @@ +--- +"nostream": minor +--- + +chore: sunset the StaticMirroringWorker + +The static mirroring worker is removed, together with the `mirroring.static` settings and the mirror health table and `/admin/ops/mirrors` route of the admin console. A `mirroring.static` that is still configured is ignored and logs a warning at start-up. To sync events with another relay, use NIP-77 (Negentropy), for example with `strfry sync`. diff --git a/CONFIGURATION.md b/CONFIGURATION.md index e5888025d..c5490a2a0 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -165,7 +165,7 @@ The settings below are listed in alphabetical order by name. Please keep this ta | limits.event.content[].maxLength | Maximum length of `content`. Defaults to 1 MB. Disabled when set to zero. | | limits.event.createdAt.maxNegativeDelta | Maximum number of seconds an event's `created_at` can be in the past. Defaults to zero. Disabled when set to zero. | | limits.event.createdAt.maxPositiveDelta | Maximum number of seconds an event's `created_at` can be in the future. Defaults to 900 (15 minutes). Disabled when set to zero. | -| limits.event.eventId.minLeadingZeroBits | Leading zero bits required on every incoming event for proof of work. Defaults to zero. Disabled when set to zero. Ignored on the client path while `limits.event.pow.enabled` is true (mirrored events from `static-mirroring-worker.ts` still enforce this static value). | +| limits.event.eventId.minLeadingZeroBits | Leading zero bits required on every incoming event for proof of work. Defaults to zero. Disabled when set to zero. Ignored on the client path while `limits.event.pow.enabled` is true. | | limits.event.kind.blacklist | List of event kinds to always reject. Leave empty to allow any. | | limits.event.kind.whitelist | List of event kinds to always allow. Leave empty to allow any. | | limits.event.pow.ceilingBits | Maximum adaptive PoW difficulty, reached at approximately 2x `targetEventsPerSecond` and beyond. | @@ -194,11 +194,7 @@ The settings below are listed in alphabetical order by name. Please keep this ta | limits.client.subscription.maxFilters | Maximum number of filters per subscription. Defaults to 10. Disabled when set to zero. | | limits.message.rateLimits[].period | Rate limiting period in milliseconds. For `sliding_window`: the time window. For `ewma`: the half-life of the decay function. | | limits.message.rateLimits[].rate | Maximum number of messages during period. | -| mirroring.static[].address | Address of mirrored relay. (e.g. ws://100.100.100.100:8008) | -| mirroring.static[].filters | Subscription filters used to mirror. | -| mirroring.static[].limits.event | Event limit overrides for this mirror. See configurations under limits.event. | -| mirroring.static[].secret | Secret to pass to relays. Nostream relays only. Optional. | -| mirroring.static[].skipAdmissionCheck | Disable the admission fee check for events coming from this mirror. | +| mirroring.static | Removed: the static mirroring worker no longer exists, so this setting is ignored and logs a warning at start-up. To sync events with another relay, use NIP-77 (Negentropy) from a client that speaks it, for example `strfry sync`, against a relay with `nip77.enabled` set. | | network.maxPayloadSize | Maximum number of bytes accepted per WebSocket frame | | network.remoteIpHeader | HTTP header from proxy containing IP address from client. | | nip05.domainBlacklist | List of domains blocked from NIP-05 verification. Authors with NIP-05 at these domains will be rejected. | @@ -226,7 +222,7 @@ The settings below are listed in alphabetical order by name. Please keep this ta | nip66.dnsCacheTtlSeconds | DNS cache TTL in seconds for repeated probe lookups of the same hostname. Defaults to 300. | | nip66.enabled | Enable NIP-66 relay monitoring. When true, starts a `relay-monitor` cluster worker that probes targets on an interval and stores the latest snapshot in Redis. Defaults to false. | | nip66.probeIntervalSeconds | Seconds between scheduled relay probe runs. Defaults to 3600. | -| nip66.targets | Public WebSocket URLs to probe (for example `wss://relay.example.com`). When empty, defaults to `info.relay_url`. Addresses from `mirroring.static[]` are probed for the admin snapshot but are not published as kind `30166` discovery events. | +| nip66.targets | Public WebSocket URLs to probe (for example `wss://relay.example.com`). When empty, defaults to `info.relay_url`. | | nip66.geohash | Optional NIP-52 geohash describing this monitor's network vantage. Published on kind `10166` monitor announcements when set. | | nip66.historyRetentionRuns | Maximum probe run snapshots kept for the admin Network Health history API and timeline. Defaults to 168. | | nip66.historyRetentionHours | Optional maximum age in hours for retained probe history. When unset, history is trimmed by run count only. | @@ -265,11 +261,6 @@ The settings below are listed in alphabetical order by name. Please keep this ta | limits.admissionCheck.rateLimits[].rate | Maximum number of admission checks during period. | | limits.admissionCheck.ipWhitelist | List of IPs (IPv4 or IPv6) to ignore rate limits. | | limits.rateLimiter.strategy | Rate limiting strategy. Either `ewma` or `sliding_window`. Defaults to `ewma`. When using `ewma`, the `period` field in each rate limit serves as the half-life for the exponential decay function. Note: when switching from `sliding_window` to `ewma`, consider increasing `rate` values slightly as EWMA penalizes bursty behavior more aggressively. | -| mirroring.static[].address | Address of mirrored relay. (e.g. ws://100.100.100.100:8008) | -| mirroring.static[].filters | Subscription filters used to mirror. | -| mirroring.static[].limits.event | Event limit overrides for this mirror. See configurations under limits.event. | -| mirroring.static[].secret | Secret to pass to relays. Nostream relays only. Optional. | -| mirroring.static[].skipAdmissionCheck | Disable the admission fee check for events coming from this mirror. | | network.maxPayloadSize | Maximum number of bytes accepted per WebSocket frame | | network.remoteIpHeader | HTTP header from proxy containing IP address from client. | | network.trustedProxies | Optional allow-list of proxy IPs allowed to set `network.remoteIpHeader`; otherwise socket remote IP is used. | diff --git a/resources/admin/assets/admin-ops.js b/resources/admin/assets/admin-ops.js index 10a429d82..db08005cb 100644 --- a/resources/admin/assets/admin-ops.js +++ b/resources/admin/assets/admin-ops.js @@ -2,16 +2,14 @@ const config = window.__ADMIN_DASHBOARD__ || { pathPrefix: '' } const adminBase = `${config.pathPrefix || ''}/admin` - const mirrorsBody = document.getElementById('ops-mirrors-body') - if (!mirrorsBody) { + const workersBody = document.getElementById('ops-workers-body') + if (!workersBody) { return } const elements = { - mirrorsSync: document.getElementById('ops-mirrors-sync'), - mirrorsBody, workersSync: document.getElementById('ops-workers-sync'), - workersBody: document.getElementById('ops-workers-body'), + workersBody, nip05Error: document.getElementById('ops-nip05-error'), nip05Domain: document.getElementById('ops-nip05-domain'), nip05Verified: document.getElementById('ops-nip05-verified'), @@ -80,34 +78,6 @@ return response } - const renderMirrors = (mirrors) => { - if (!Array.isArray(mirrors) || !mirrors.length) { - elements.mirrorsBody.innerHTML = - 'No mirrors configured.' - return - } - - elements.mirrorsBody.innerHTML = mirrors - .map((mirror) => { - const heartbeat = mirror.heartbeat - const state = heartbeat?.connectionState ?? 'unknown' - const reconnects = heartbeat?.reconnectCount ?? 0 - const lastEvent = formatTime(heartbeat?.lastEventAt) - const errorNote = heartbeat?.lastError - ? `${escapeHtml(heartbeat.lastError)}` - : '' - return ` - ${escapeHtml(mirror.index)} - ${escapeHtml(mirror.targetUrl)}${errorNote} - ${escapeHtml(state)} - ${escapeHtml(reconnects)} - ${escapeHtml(lastEvent)} - ${signalBadge(mirror.stale)} - ` - }) - .join('') - } - const renderWorkers = (workers) => { if (!Array.isArray(workers) || !workers.length) { elements.workersBody.innerHTML = @@ -222,18 +192,6 @@ elements.storageError.classList.remove('d-none') } - const loadMirrors = async () => { - const response = await adminFetch('/ops/mirrors') - if (!response.ok) { - throw new Error('Failed to load mirrors') - } - const payload = await response.json() - renderMirrors(payload.mirrors) - if (elements.mirrorsSync) { - elements.mirrorsSync.innerHTML = `> mirrors: updated ${formatTime(new Date().toISOString())}` - } - } - const loadWorkers = async () => { const response = await adminFetch('/ops/workers') if (!response.ok) { @@ -327,7 +285,7 @@ const refreshAll = async () => { try { - await Promise.all([loadMirrors(), loadWorkers(), loadNip05()]) + await Promise.all([loadWorkers(), loadNip05()]) } catch { showNip05Error('Unable to refresh operations data.') } diff --git a/resources/admin/assets/notifications.js b/resources/admin/assets/notifications.js index e76e97ac9..8d60629a9 100644 --- a/resources/admin/assets/notifications.js +++ b/resources/admin/assets/notifications.js @@ -63,7 +63,6 @@ 'probe.failed': true, 'probe.recovered': true, 'ssl.expiring': true, - 'mirror.disconnected': true, }) const newTargetId = () => { diff --git a/resources/admin/dashboard.html b/resources/admin/dashboard.html index 808ea93e6..33bb3d3b5 100644 --- a/resources/admin/dashboard.html +++ b/resources/admin/dashboard.html @@ -204,29 +204,6 @@

Charts

-
-

Mirror health

-

> mirrors: not loaded

-
- - - - - - - - - - - - - - - -
Static mirror connection status
IndexTargetStateReconnectsLast eventSignal
No mirrors configured.
-
-
-

Worker ops

> workers: not loaded

@@ -433,12 +410,6 @@

Operator notifications

-
-
- - -
-
@@ -511,7 +482,7 @@

Delivery history

Diagnostics export

-

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.

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' },