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') + }) +})