From dc3cbcbb52039b669ab8ce61473c5bdb1214b136 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Sun, 20 Sep 2026 22:28:10 +0530 Subject: [PATCH 1/2] feat(admin): add GET/PATCH /admin/notifications API Expose operator notification config with secret redaction, validated PATCH updates, delivery log filters, and relay-named test messages. Closes #760. --- .changeset/admin-notifications-api.md | 7 ++ src/@types/repositories.ts | 6 +- ...et-notification-delivery-log-controller.ts | 22 +++++- .../admin/get-notifications-controller.ts | 12 +++ .../admin/patch-notifications-controller.ts | 78 +++++++++++++++++++ ...-admin-notifications-controller-factory.ts | 6 ++ ...-admin-notifications-controller-factory.ts | 9 +++ .../notification-delivery-log-repository.ts | 19 ++++- src/routes/admin/index.ts | 16 ++++ src/schemas/admin-notifications-schema.ts | 37 +++++++++ src/services/operator-notification-service.ts | 3 +- src/utils/admin-notifications-settings.ts | 70 +++++++++++++++++ .../get-notifications-controller.spec.ts | 48 ++++++++++++ test/unit/routes/admin.spec.ts | 18 ++++- .../admin-notifications-settings.spec.ts | 44 +++++++++++ 15 files changed, 388 insertions(+), 7 deletions(-) create mode 100644 .changeset/admin-notifications-api.md create mode 100644 src/controllers/admin/get-notifications-controller.ts create mode 100644 src/controllers/admin/patch-notifications-controller.ts create mode 100644 src/factories/controllers/get-admin-notifications-controller-factory.ts create mode 100644 src/factories/controllers/patch-admin-notifications-controller-factory.ts create mode 100644 src/schemas/admin-notifications-schema.ts create mode 100644 src/utils/admin-notifications-settings.ts create mode 100644 test/unit/controllers/admin/get-notifications-controller.spec.ts create mode 100644 test/unit/utils/admin-notifications-settings.spec.ts diff --git a/.changeset/admin-notifications-api.md b/.changeset/admin-notifications-api.md new file mode 100644 index 00000000..bb35cb72 --- /dev/null +++ b/.changeset/admin-notifications-api.md @@ -0,0 +1,7 @@ +--- +"nostream": minor +--- + +feat(admin): add GET/PATCH /admin/notifications API for operator alert config + +Closes #760. diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index b119ebb0..0216c19f 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -110,7 +110,11 @@ export interface INotificationDeliveryLogRepository { }, client?: DatabaseClient, ): Promise - findRecent(limit?: number, client?: DatabaseClient): Promise + findRecent( + limit?: number, + filters?: { status?: NotificationDeliveryStatus; eventType?: string }, + client?: DatabaseClient, + ): Promise findSuccessfulTargetIds(outboxId: string, client?: DatabaseClient): Promise deleteOlderThan(cutoff: Date, client?: DatabaseClient): Promise } diff --git a/src/controllers/admin/get-notification-delivery-log-controller.ts b/src/controllers/admin/get-notification-delivery-log-controller.ts index 9873776f..93209970 100644 --- a/src/controllers/admin/get-notification-delivery-log-controller.ts +++ b/src/controllers/admin/get-notification-delivery-log-controller.ts @@ -1,6 +1,7 @@ import { Request, Response } from 'express' import { IController } from '../../@types/controllers' +import { NotificationDeliveryStatus } from '../../@types/operator-notifications' import { INotificationDeliveryLogRepository } from '../../@types/repositories' export class GetAdminNotificationDeliveryLogController implements IController { @@ -19,7 +20,26 @@ export class GetAdminNotificationDeliveryLogController implements IController { limit = Math.min(parsed, 200) } - const entries = await this.deliveryLogRepository.findRecent(limit) + let status: NotificationDeliveryStatus | undefined + if (_request.query.status !== undefined) { + const value = String(_request.query.status) + if (value !== NotificationDeliveryStatus.SUCCESS && value !== NotificationDeliveryStatus.FAILED) { + response.status(400).setHeader('content-type', 'application/json').send({ + error: 'status must be success or failed', + }) + return + } + status = value + } + + const eventType = + _request.query.eventType !== undefined ? String(_request.query.eventType).trim() : undefined + if (eventType !== undefined && !eventType) { + response.status(400).setHeader('content-type', 'application/json').send({ error: 'eventType must be non-empty' }) + return + } + + const entries = await this.deliveryLogRepository.findRecent(limit, { status, eventType }) response.status(200).setHeader('content-type', 'application/json').send({ entries: entries.map((entry) => ({ diff --git a/src/controllers/admin/get-notifications-controller.ts b/src/controllers/admin/get-notifications-controller.ts new file mode 100644 index 00000000..93160c69 --- /dev/null +++ b/src/controllers/admin/get-notifications-controller.ts @@ -0,0 +1,12 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { getRedactedAdminNotifications } from '../../utils/admin-notifications-settings' + +export class GetAdminNotificationsController implements IController { + public async handleRequest(_request: Request, response: Response): Promise { + response.status(200).setHeader('content-type', 'application/json').send({ + notifications: getRedactedAdminNotifications(), + }) + } +} diff --git a/src/controllers/admin/patch-notifications-controller.ts b/src/controllers/admin/patch-notifications-controller.ts new file mode 100644 index 00000000..fbfa5f79 --- /dev/null +++ b/src/controllers/admin/patch-notifications-controller.ts @@ -0,0 +1,78 @@ +import { Request, Response } from 'express' +import { mergeDeepRight } from 'ramda' + +import { IController } from '../../@types/controllers' +import { INotificationOutboxRepository } from '../../@types/repositories' +import { Settings } from '../../@types/settings' +import { OperatorNotificationEventType } from '../../@types/operator-notifications' +import { createLogger } from '../../factories/logger-factory' +import { adminNotificationsPatchBodySchema } from '../../schemas/admin-notifications-schema' +import { + getMergedAdminNotifications, + getRedactedAdminNotifications, + mergeAdminNotificationsPatch, +} from '../../utils/admin-notifications-settings' +import { + appendSettingsAuditLog, + loadDefaults, + loadMergedSettings, + loadUserSettings, + saveSettings, + validateSettings, +} from '../../utils/settings-config' +import { validateSchema } from '../../utils/validation' + +const logger = createLogger('patch-admin-notifications-controller') + +export class PatchAdminNotificationsController implements IController { + public constructor(private readonly notificationOutboxRepository: INotificationOutboxRepository) {} + + public async handleRequest(request: Request, response: Response): Promise { + const validation = validateSchema(adminNotificationsPatchBodySchema)(request.body) + if (validation.error) { + response.status(400).setHeader('content-type', 'application/json').send({ error: 'Invalid request' }) + return + } + + const merged = loadMergedSettings() + const current = getMergedAdminNotifications(merged) + const nextNotifications = mergeAdminNotificationsPatch(current, validation.value) + + const userSettings = loadUserSettings() as Settings + const nextUserSettings = mergeDeepRight(userSettings, { + admin: { + ...userSettings.admin, + notifications: nextNotifications, + }, + }) as Settings + + const mergedNext = mergeDeepRight(loadDefaults(), nextUserSettings) as Settings + + const issues = validateSettings(mergedNext) + if (issues.length > 0) { + response.status(400).setHeader('content-type', 'application/json').send({ error: 'Validation failed', issues }) + return + } + + saveSettings(nextUserSettings) + appendSettingsAuditLog({ + action: 'settings.updated', + changes: [{ path: 'admin.notifications', reload: 'hot-reload' }], + remoteAddress: request.ip, + }) + + try { + await this.notificationOutboxRepository.enqueue(OperatorNotificationEventType.SETTINGS_CHANGED, { + changes: [{ path: 'admin.notifications', reload: 'hot-reload' }], + remoteAddress: request.ip, + }) + } catch (error) { + logger.error('Unable to enqueue notifications settings outbox event', error) + } + + response.status(200).setHeader('content-type', 'application/json').send({ + ok: true, + notifications: getRedactedAdminNotifications(), + }) + } +} diff --git a/src/factories/controllers/get-admin-notifications-controller-factory.ts b/src/factories/controllers/get-admin-notifications-controller-factory.ts new file mode 100644 index 00000000..d144e583 --- /dev/null +++ b/src/factories/controllers/get-admin-notifications-controller-factory.ts @@ -0,0 +1,6 @@ +import { GetAdminNotificationsController } from '../../controllers/admin/get-notifications-controller' +import { IController } from '../../@types/controllers' + +export const createGetAdminNotificationsController = (): IController => { + return new GetAdminNotificationsController() +} diff --git a/src/factories/controllers/patch-admin-notifications-controller-factory.ts b/src/factories/controllers/patch-admin-notifications-controller-factory.ts new file mode 100644 index 00000000..57a6d62a --- /dev/null +++ b/src/factories/controllers/patch-admin-notifications-controller-factory.ts @@ -0,0 +1,9 @@ +import { PatchAdminNotificationsController } from '../../controllers/admin/patch-notifications-controller' +import { IController } from '../../@types/controllers' +import { getMasterDbClient } from '../../database/client' +import { NotificationOutboxRepository } from '../../repositories/notification-outbox-repository' + +export const createPatchAdminNotificationsController = (): IController => { + const notificationOutboxRepository = new NotificationOutboxRepository(getMasterDbClient()) + return new PatchAdminNotificationsController(notificationOutboxRepository) +} diff --git a/src/repositories/notification-delivery-log-repository.ts b/src/repositories/notification-delivery-log-repository.ts index b83bd6ea..ee508933 100644 --- a/src/repositories/notification-delivery-log-repository.ts +++ b/src/repositories/notification-delivery-log-repository.ts @@ -64,11 +64,24 @@ export class NotificationDeliveryLogRepository implements INotificationDeliveryL .pluck('target_id') } - public async findRecent(limit = 50, client: DatabaseClient = this.dbClient): Promise { - const rows = await client('notification_delivery_log') + public async findRecent( + limit = 50, + filters?: { status?: NotificationDeliveryStatus; eventType?: string }, + client: DatabaseClient = this.dbClient, + ): Promise { + let query = client('notification_delivery_log') .orderBy('created_at', 'desc') .limit(limit) - .select('*') + + if (filters?.status) { + query = query.where('status', filters.status) + } + + if (filters?.eventType) { + query = query.where('event_type', filters.eventType) + } + + const rows = await query.select('*') return rows.map(fromDB) } diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index c2b9f9f6..0f73629f 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -1,6 +1,8 @@ import express, { json, Router } from 'express' import { createGetAdminNotificationDeliveryLogController } from '../../factories/controllers/get-admin-notification-delivery-log-controller-factory' +import { createGetAdminNotificationsController } from '../../factories/controllers/get-admin-notifications-controller-factory' +import { createPatchAdminNotificationsController } from '../../factories/controllers/patch-admin-notifications-controller-factory' import { createGetAdminHealthController } from '../../factories/controllers/get-admin-health-controller-factory' import { createGetAdminMetricsController } from '../../factories/controllers/get-admin-metrics-controller-factory' import { createGetAdminNetworkHealthController } from '../../factories/controllers/get-admin-network-health-controller-factory' @@ -106,6 +108,20 @@ router.post( adminAuthMiddleware, withAdminController(createPostAdminSettingsRestoreController), ) +router.get( + '/notifications', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createGetAdminNotificationsController), +) +router.patch( + '/notifications', + adminRateLimitMiddleware, + adminAuthGateMiddleware, + adminJsonBodyMiddleware, + adminAuthMiddleware, + withAdminController(createPatchAdminNotificationsController), +) router.get( '/notifications/deliveries', adminRateLimitMiddleware, diff --git a/src/schemas/admin-notifications-schema.ts b/src/schemas/admin-notifications-schema.ts new file mode 100644 index 00000000..c7d05c0a --- /dev/null +++ b/src/schemas/admin-notifications-schema.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' + +const targetSchema = z + .object({ + id: z.string().min(1), + type: z.enum(['http', 'discord', 'slack', 'telegram']), + enabled: z.boolean(), + url: z.string().optional(), + botToken: z.string().optional(), + chatId: z.string().optional(), + }) + .strict() + +export const adminNotificationsPatchBodySchema = z + .object({ + enabled: z.boolean().optional(), + targets: z.array(targetSchema).optional(), + events: z + .object({ + 'admission.invoice.created': z.boolean().optional(), + 'admission.invoice.paid': z.boolean().optional(), + 'admission.invoice.failed': z.boolean().optional(), + 'settings.changed': z.boolean().optional(), + 'relay.restarted': z.boolean().optional(), + }) + .strict() + .optional(), + retry: z + .object({ + maxAttempts: z.number().int().min(1).optional(), + baseDelayMs: z.number().int().min(0).optional(), + }) + .strict() + .optional(), + deliveryLogRetentionDays: z.number().int().min(1).optional(), + }) + .strict() diff --git a/src/services/operator-notification-service.ts b/src/services/operator-notification-service.ts index 3a9a5576..77d59717 100644 --- a/src/services/operator-notification-service.ts +++ b/src/services/operator-notification-service.ts @@ -100,9 +100,10 @@ export class OperatorNotificationService implements INotificationDispatcher { throw new Error(`Unknown notification target: ${targetId}`) } + const relayName = this.settings().info?.name?.trim() || this.settings().info.relay_url const envelope = this.buildEnvelope(OperatorNotificationEventType.RELAY_RESTARTED, { test: true, - message: 'Operator notification test delivery', + message: `Test notification from ${relayName}`, }) await deliverToTarget(target, envelope) diff --git a/src/utils/admin-notifications-settings.ts b/src/utils/admin-notifications-settings.ts new file mode 100644 index 00000000..20b2c627 --- /dev/null +++ b/src/utils/admin-notifications-settings.ts @@ -0,0 +1,70 @@ +import { AdminNotificationsSettings, OperatorNotificationTarget } from '../@types/operator-notifications' +import { Settings } from '../@types/settings' +import { loadDefaults, loadMergedSettings } from './settings-config' +import { redactSettingsSecrets } from './settings-redaction' + +const REDACTED_SECRET = '***' + +export const getMergedAdminNotifications = (settings: Settings = loadMergedSettings()): AdminNotificationsSettings => { + const defaults = loadDefaults().admin?.notifications + const configured = settings.admin?.notifications + + return { + enabled: configured?.enabled ?? defaults?.enabled ?? false, + targets: configured?.targets ?? defaults?.targets ?? [], + events: { ...defaults?.events, ...configured?.events }, + retry: { + maxAttempts: configured?.retry?.maxAttempts ?? defaults?.retry?.maxAttempts ?? 5, + baseDelayMs: configured?.retry?.baseDelayMs ?? defaults?.retry?.baseDelayMs ?? 1000, + }, + deliveryLogRetentionDays: + configured?.deliveryLogRetentionDays ?? defaults?.deliveryLogRetentionDays ?? 30, + } +} + +export const getRedactedAdminNotifications = (): AdminNotificationsSettings => { + const merged = loadMergedSettings() + const notifications = getMergedAdminNotifications(merged) + const redacted = redactSettingsSecrets({ admin: { notifications } }) as Settings + + return getMergedAdminNotifications(redacted) +} + +const isRedactedSecret = (value: unknown): boolean => value === REDACTED_SECRET + +const mergeTargetSecrets = ( + incoming: OperatorNotificationTarget, + existing: OperatorNotificationTarget | undefined, +): OperatorNotificationTarget => { + const merged: OperatorNotificationTarget = { ...incoming } + + if (existing) { + if (isRedactedSecret(incoming.url) || (incoming.url === undefined && existing.url)) { + merged.url = existing.url + } + if (isRedactedSecret(incoming.botToken) || (incoming.botToken === undefined && existing.botToken)) { + merged.botToken = existing.botToken + } + } + + return merged +} + +export const mergeAdminNotificationsPatch = ( + current: AdminNotificationsSettings, + patch: Partial, +): AdminNotificationsSettings => { + const next: AdminNotificationsSettings = { + ...current, + ...patch, + events: patch.events ? { ...current.events, ...patch.events } : current.events, + retry: patch.retry ? { ...current.retry, ...patch.retry } : current.retry, + } + + if (patch.targets) { + const existingById = new Map(current.targets.map((target) => [target.id, target])) + next.targets = patch.targets.map((target) => mergeTargetSecrets(target, existingById.get(target.id))) + } + + return next +} diff --git a/test/unit/controllers/admin/get-notifications-controller.spec.ts b/test/unit/controllers/admin/get-notifications-controller.spec.ts new file mode 100644 index 00000000..d72d674b --- /dev/null +++ b/test/unit/controllers/admin/get-notifications-controller.spec.ts @@ -0,0 +1,48 @@ +import chai from 'chai' +import Sinon from 'sinon' + +import { GetAdminNotificationsController } from '../../../../src/controllers/admin/get-notifications-controller' +import * as adminNotificationsSettings from '../../../../src/utils/admin-notifications-settings' + +const { expect } = chai + +describe('GetAdminNotificationsController', () => { + let sandbox: Sinon.SinonSandbox + let response: { status: Sinon.SinonStub; setHeader: Sinon.SinonStub; send: Sinon.SinonStub } + + beforeEach(() => { + sandbox = Sinon.createSandbox() + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + sandbox.stub(adminNotificationsSettings, 'getRedactedAdminNotifications').returns({ + enabled: false, + targets: [], + events: {}, + retry: { maxAttempts: 5, baseDelayMs: 1000 }, + }) + }) + + afterEach(() => { + sandbox.restore() + }) + + it('returns redacted notifications config', async () => { + const controller = new GetAdminNotificationsController() + await controller.handleRequest({} as any, response as any) + + expect(response.status.calledOnceWithExactly(200)).to.equal(true) + expect( + response.send.calledOnceWith({ + notifications: { + enabled: false, + targets: [], + events: {}, + retry: { maxAttempts: 5, baseDelayMs: 1000 }, + }, + }), + ).to.equal(true) + }) +}) diff --git a/test/unit/routes/admin.spec.ts b/test/unit/routes/admin.spec.ts index a06064cc..de96ae29 100644 --- a/test/unit/routes/admin.spec.ts +++ b/test/unit/routes/admin.spec.ts @@ -8,6 +8,7 @@ import { EventKinds, EventTags } from '../../../src/constants/base' import * as getAdminHealthControllerFactory from '../../../src/factories/controllers/get-admin-health-controller-factory' import * as getAdminMetricsControllerFactory from '../../../src/factories/controllers/get-admin-metrics-controller-factory' import * as getAdminNetworkHealthControllerFactory from '../../../src/factories/controllers/get-admin-network-health-controller-factory' +import * as getAdminNotificationsControllerFactory from '../../../src/factories/controllers/get-admin-notifications-controller-factory' import * as adminRateLimitMiddleware from '../../../src/handlers/request-handlers/admin-rate-limit-middleware' import * as rateLimiterMiddleware from '../../../src/handlers/request-handlers/rate-limiter-middleware' import * as settingsFactory from '../../../src/factories/settings-factory' @@ -21,6 +22,7 @@ describe('admin router', () => { let createGetAdminHealthControllerStub: Sinon.SinonStub let createGetAdminMetricsControllerStub: Sinon.SinonStub let createGetAdminNetworkHealthControllerStub: Sinon.SinonStub + let createGetAdminNotificationsControllerStub: Sinon.SinonStub let createSettingsStub: Sinon.SinonStub let rateLimiterMiddlewareStub: Sinon.SinonStub let adminRateLimitMiddlewareStub: Sinon.SinonStub @@ -80,6 +82,17 @@ describe('admin router', () => { .send({ snapshot: null }) }, } as any) + createGetAdminNotificationsControllerStub = Sinon.stub( + getAdminNotificationsControllerFactory, + 'createGetAdminNotificationsController', + ).returns({ + handleRequest: async (_request: any, response: any) => { + response + .status(200) + .setHeader('content-type', 'application/json') + .send({ notifications: { enabled: false, targets: [], events: {}, retry: { maxAttempts: 5, baseDelayMs: 1000 } } }) + }, + } as any) createSettingsStub = Sinon.stub(settingsFactory, 'createSettings').returns(settings as any) const passthrough = async (_request: any, _response: any, next: any) => { next() @@ -110,6 +123,7 @@ describe('admin router', () => { createGetAdminHealthControllerStub?.restore() createGetAdminMetricsControllerStub?.restore() createGetAdminNetworkHealthControllerStub?.restore() + createGetAdminNotificationsControllerStub?.restore() createSettingsStub?.restore() rateLimiterMiddlewareStub?.restore() adminRateLimitMiddlewareStub?.restore() @@ -190,12 +204,14 @@ describe('admin router', () => { const healthResponse = await axios.get(`${baseUrl}/health`, { validateStatus: () => true }) const metricsResponse = await axios.get(`${baseUrl}/metrics`, { validateStatus: () => true }) const networkHealthResponse = await axios.get(`${baseUrl}/network-health`, { validateStatus: () => true }) + const notificationsResponse = await axios.get(`${baseUrl}/notifications`, { validateStatus: () => true }) expect(sessionResponse.status).to.equal(401) expect(healthResponse.status).to.equal(401) expect(metricsResponse.status).to.equal(401) expect(networkHealthResponse.status).to.equal(401) - expect(rateLimiterMiddlewareStub.callCount).to.equal(4) + expect(notificationsResponse.status).to.equal(401) + expect(rateLimiterMiddlewareStub.callCount).to.equal(5) }) it('authenticates a protected route with a signed NIP-98 event', async () => { diff --git a/test/unit/utils/admin-notifications-settings.spec.ts b/test/unit/utils/admin-notifications-settings.spec.ts new file mode 100644 index 00000000..983360a7 --- /dev/null +++ b/test/unit/utils/admin-notifications-settings.spec.ts @@ -0,0 +1,44 @@ +import chai from 'chai' + +import { + getMergedAdminNotifications, + mergeAdminNotificationsPatch, +} from '../../../src/utils/admin-notifications-settings' + +const { expect } = chai + +describe('admin-notifications-settings', () => { + it('merges patch values and preserves redacted webhook secrets', () => { + const current = getMergedAdminNotifications({ + admin: { + notifications: { + enabled: true, + targets: [ + { + id: 'discord-1', + type: 'discord', + enabled: true, + url: 'https://discord.com/api/webhooks/secret', + }, + ], + events: { 'settings.changed': true }, + retry: { maxAttempts: 5, baseDelayMs: 1000 }, + }, + }, + } as any) + + const next = mergeAdminNotificationsPatch(current, { + targets: [ + { + id: 'discord-1', + type: 'discord', + enabled: false, + url: '***', + }, + ], + }) + + expect(next.targets[0].enabled).to.equal(false) + expect(next.targets[0].url).to.equal('https://discord.com/api/webhooks/secret') + }) +}) From f56e97e56aa68c9634617a5ea10e9532e66f0ef7 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Fri, 25 Sep 2026 17:16:15 +0530 Subject: [PATCH 2/2] fix(admin): harden notification PATCH merge and delivery logging Reject persisting redacted placeholders on new targets, add PATCH controller coverage, reload settings in the maintenance worker, and record test deliveries in the delivery log. --- src/app/maintenance-worker.ts | 11 ++ src/services/operator-notification-service.ts | 29 +++- src/utils/admin-notifications-settings.ts | 17 ++- .../patch-notifications-controller.spec.ts | 142 ++++++++++++++++++ .../operator-notification-service.spec.ts | 14 ++ .../admin-notifications-settings.spec.ts | 53 +++++++ 6 files changed, 261 insertions(+), 5 deletions(-) create mode 100644 test/unit/controllers/admin/patch-notifications-controller.spec.ts diff --git a/src/app/maintenance-worker.ts b/src/app/maintenance-worker.ts index d5ed3fdd..acd9114b 100644 --- a/src/app/maintenance-worker.ts +++ b/src/app/maintenance-worker.ts @@ -20,7 +20,10 @@ import { import { InvoiceStatus } from '../@types/invoice' import { isExpiredInvoice } from '../utils/invoice' import { Nip05Verification } from '../@types/nip05' +import { FSWatcher } from 'fs' + import { Settings } from '../@types/settings' +import { SettingsStatic } from '../utils/settings' import { shutdownMetricsTelemetry } from '../telemetry/metrics' const UPDATE_INVOICE_INTERVAL = 60000 @@ -79,6 +82,7 @@ export function applyReverificationOutcome( export class MaintenanceWorker implements IRunnable { private interval: NodeJS.Timeout | undefined private isRunning = false + private watchers: FSWatcher[] | undefined /** * Where the next pass starts. Without it every pass re-reads the oldest ten, so * ten invoices that never resolve starve everything behind them. @@ -117,6 +121,8 @@ export class MaintenanceWorker implements IRunnable { } public run(): void { + this.watchers = SettingsStatic.watchSettings() + this.interval = setInterval(async () => { if (this.isRunning) { logger('skipping scheduled maintenance run because previous run is still in progress') @@ -289,6 +295,11 @@ export class MaintenanceWorker implements IRunnable { public close(callback?: () => void) { logger('closing') clearInterval(this.interval) + if (Array.isArray(this.watchers)) { + for (const watcher of this.watchers) { + watcher.close() + } + } if (typeof callback === 'function') { callback() } diff --git a/src/services/operator-notification-service.ts b/src/services/operator-notification-service.ts index 77d59717..65cc9dc0 100644 --- a/src/services/operator-notification-service.ts +++ b/src/services/operator-notification-service.ts @@ -101,12 +101,37 @@ export class OperatorNotificationService implements INotificationDispatcher { } const relayName = this.settings().info?.name?.trim() || this.settings().info.relay_url - const envelope = this.buildEnvelope(OperatorNotificationEventType.RELAY_RESTARTED, { + const eventType = OperatorNotificationEventType.RELAY_RESTARTED + const envelope = this.buildEnvelope(eventType, { test: true, message: `Test notification from ${relayName}`, }) - await deliverToTarget(target, envelope) + try { + await deliverToTarget(target, envelope) + await this.deliveryLogRepository.append({ + outboxId: null, + eventType, + targetId: target.id, + targetType: target.type, + status: NotificationDeliveryStatus.SUCCESS, + attemptNumber: 1, + errorSnippet: null, + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.error('test delivery failed for %s: %s', maskTargetForLog(target), message) + await this.deliveryLogRepository.append({ + outboxId: null, + eventType, + targetId: target.id, + targetType: target.type, + status: NotificationDeliveryStatus.FAILED, + attemptNumber: 1, + errorSnippet: message.slice(0, 2000), + }) + throw error + } } public getMaxAttempts(): number { diff --git a/src/utils/admin-notifications-settings.ts b/src/utils/admin-notifications-settings.ts index 20b2c627..1d801a14 100644 --- a/src/utils/admin-notifications-settings.ts +++ b/src/utils/admin-notifications-settings.ts @@ -38,13 +38,24 @@ const mergeTargetSecrets = ( ): OperatorNotificationTarget => { const merged: OperatorNotificationTarget = { ...incoming } - if (existing) { - if (isRedactedSecret(incoming.url) || (incoming.url === undefined && existing.url)) { + if (isRedactedSecret(incoming.url)) { + if (existing?.url) { merged.url = existing.url + } else { + delete merged.url } - if (isRedactedSecret(incoming.botToken) || (incoming.botToken === undefined && existing.botToken)) { + } else if (incoming.url === undefined && existing?.url) { + merged.url = existing.url + } + + if (isRedactedSecret(incoming.botToken)) { + if (existing?.botToken) { merged.botToken = existing.botToken + } else { + delete merged.botToken } + } else if (incoming.botToken === undefined && existing?.botToken) { + merged.botToken = existing.botToken } return merged diff --git a/test/unit/controllers/admin/patch-notifications-controller.spec.ts b/test/unit/controllers/admin/patch-notifications-controller.spec.ts new file mode 100644 index 00000000..9698bcfa --- /dev/null +++ b/test/unit/controllers/admin/patch-notifications-controller.spec.ts @@ -0,0 +1,142 @@ +import chai from 'chai' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { OperatorNotificationEventType } from '../../../../src/@types/operator-notifications' +import * as adminNotificationsSettings from '../../../../src/utils/admin-notifications-settings' +import * as settingsConfig from '../../../../src/utils/settings-config' + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { PatchAdminNotificationsController } = require('../../../../src/controllers/admin/patch-notifications-controller') + +chai.use(sinonChai) + +const { expect } = chai + +const baseNotifications = { + enabled: false, + targets: [ + { + id: 'discord-1', + type: 'discord' as const, + enabled: true, + url: 'https://discord.com/api/webhooks/secret', + }, + ], + events: { 'settings.changed': true }, + retry: { maxAttempts: 5, baseDelayMs: 1000 }, + deliveryLogRetentionDays: 30, +} + +const baseMergedSettings = { + info: { relay_url: 'wss://relay.example' }, + admin: { enabled: true, notifications: baseNotifications }, +} + +describe('PatchAdminNotificationsController', () => { + let sandbox: Sinon.SinonSandbox + let outboxRepository: { enqueue: Sinon.SinonStub } + let response: { status: Sinon.SinonStub; setHeader: Sinon.SinonStub; send: Sinon.SinonStub } + + beforeEach(() => { + sandbox = Sinon.createSandbox() + outboxRepository = { enqueue: sandbox.stub().resolves() } + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + + sandbox.stub(settingsConfig, 'loadMergedSettings').returns(baseMergedSettings as any) + sandbox.stub(settingsConfig, 'loadUserSettings').returns({ admin: { notifications: baseNotifications } } as any) + sandbox.stub(settingsConfig, 'saveSettings') + sandbox.stub(settingsConfig, 'appendSettingsAuditLog') + sandbox.stub(adminNotificationsSettings, 'getRedactedAdminNotifications').returns({ + ...baseNotifications, + targets: [{ id: 'discord-1', type: 'discord', enabled: true, url: '***' }], + }) + }) + + afterEach(() => { + sandbox.restore() + }) + + it('returns 400 for invalid request bodies', async () => { + const controller = new PatchAdminNotificationsController(outboxRepository as any) + await controller.handleRequest({ body: { retry: { maxAttempts: 'five' } }, ip: '127.0.0.1' } as any, response as any) + + expect(response.status.calledOnceWithExactly(400)).to.equal(true) + expect(response.send.calledOnceWithExactly({ error: 'Invalid request' })).to.equal(true) + expect(outboxRepository.enqueue).to.not.have.been.called + }) + + it('returns 400 when settings validation fails after merge', async () => { + const controller = new PatchAdminNotificationsController(outboxRepository as any) + await controller.handleRequest( + { + body: { + targets: [ + { + id: 'telegram-new', + type: 'telegram', + enabled: true, + botToken: '***', + chatId: '123', + }, + ], + }, + ip: '127.0.0.1', + } as any, + response as any, + ) + + expect(response.status.calledOnceWithExactly(400)).to.equal(true) + expect(response.send.firstCall.args[0].error).to.equal('Validation failed') + expect(response.send.firstCall.args[0].issues).to.be.an('array').that.is.not.empty + expect(settingsConfig.saveSettings).to.not.have.been.called + expect(outboxRepository.enqueue).to.not.have.been.called + }) + + it('persists partial updates and preserves redacted secrets for existing targets', async () => { + const controller = new PatchAdminNotificationsController(outboxRepository as any) + await controller.handleRequest( + { + body: { + enabled: true, + targets: [ + { + id: 'discord-1', + type: 'discord', + enabled: false, + url: '***', + }, + ], + }, + ip: '127.0.0.1', + } as any, + response as any, + ) + + expect(response.status.calledOnceWithExactly(200)).to.equal(true) + expect(settingsConfig.saveSettings).to.have.been.calledOnce + const saved = (settingsConfig.saveSettings as Sinon.SinonStub).firstCall.args[0] + expect(saved.admin.notifications.enabled).to.equal(true) + expect(saved.admin.notifications.targets[0].enabled).to.equal(false) + expect(saved.admin.notifications.targets[0].url).to.equal('https://discord.com/api/webhooks/secret') + expect(outboxRepository.enqueue).to.have.been.calledOnceWith( + OperatorNotificationEventType.SETTINGS_CHANGED, + Sinon.match.object, + ) + expect(response.send.firstCall.args[0].ok).to.equal(true) + }) + + it('still returns 200 when outbox enqueue fails', async () => { + outboxRepository.enqueue.rejects(new Error('db unavailable')) + const controller = new PatchAdminNotificationsController(outboxRepository as any) + + await controller.handleRequest({ body: { enabled: true }, ip: '127.0.0.1' } as any, response as any) + + expect(response.status.calledOnceWithExactly(200)).to.equal(true) + expect(settingsConfig.saveSettings).to.have.been.calledOnce + }) +}) diff --git a/test/unit/services/operator-notification-service.spec.ts b/test/unit/services/operator-notification-service.spec.ts index 2d9dba7f..e48e4d00 100644 --- a/test/unit/services/operator-notification-service.spec.ts +++ b/test/unit/services/operator-notification-service.spec.ts @@ -84,6 +84,20 @@ describe('OperatorNotificationService', () => { ).to.be.rejectedWith('network down') }) + it('logs success when a test delivery succeeds', async () => { + await service.dispatchTestTarget('discord-main') + + expect(axios.post).to.have.been.calledOnce + expect(deliveryLogRepository.append).to.have.been.calledOnce + }) + + it('logs failure and rethrows when a test delivery fails', async () => { + ;(axios.post as Sinon.SinonStub).rejects(new Error('network down')) + + await expect(service.dispatchTestTarget('discord-main')).to.be.rejectedWith('network down') + expect(deliveryLogRepository.append).to.have.been.calledOnce + }) + it('skips targets that already succeeded for the same outbox message', async () => { service = new OperatorNotificationService( () => diff --git a/test/unit/utils/admin-notifications-settings.spec.ts b/test/unit/utils/admin-notifications-settings.spec.ts index 983360a7..27997a92 100644 --- a/test/unit/utils/admin-notifications-settings.spec.ts +++ b/test/unit/utils/admin-notifications-settings.spec.ts @@ -41,4 +41,57 @@ describe('admin-notifications-settings', () => { expect(next.targets[0].enabled).to.equal(false) expect(next.targets[0].url).to.equal('https://discord.com/api/webhooks/secret') }) + + it('does not persist redacted placeholders for new targets', () => { + const current = getMergedAdminNotifications({ + admin: { + notifications: { + enabled: true, + targets: [], + events: {}, + retry: { maxAttempts: 5, baseDelayMs: 1000 }, + }, + }, + } as any) + + const next = mergeAdminNotificationsPatch(current, { + targets: [ + { + id: 'telegram-new', + type: 'telegram', + enabled: true, + botToken: '***', + chatId: '123', + }, + ], + }) + + expect(next.targets[0].botToken).to.equal(undefined) + }) + + it('does not persist redacted webhook url for new http targets', () => { + const current = getMergedAdminNotifications({ + admin: { + notifications: { + enabled: true, + targets: [], + events: {}, + retry: { maxAttempts: 5, baseDelayMs: 1000 }, + }, + }, + } as any) + + const next = mergeAdminNotificationsPatch(current, { + targets: [ + { + id: 'http-new', + type: 'http', + enabled: true, + url: '***', + }, + ], + }) + + expect(next.targets[0].url).to.equal(undefined) + }) })