Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/admin-notifications-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"nostream": minor
---

feat(admin): add GET/PATCH /admin/notifications API for operator alert config

Closes #760.
6 changes: 5 additions & 1 deletion src/@types/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,11 @@ export interface INotificationDeliveryLogRepository {
},
client?: DatabaseClient,
): Promise<void>
findRecent(limit?: number, client?: DatabaseClient): Promise<NotificationDeliveryLogEntry[]>
findRecent(
limit?: number,
filters?: { status?: NotificationDeliveryStatus; eventType?: string },
client?: DatabaseClient,
): Promise<NotificationDeliveryLogEntry[]>
findSuccessfulTargetIds(outboxId: string, client?: DatabaseClient): Promise<string[]>
deleteOlderThan(cutoff: Date, client?: DatabaseClient): Promise<number>
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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) => ({
Expand Down
12 changes: 12 additions & 0 deletions src/controllers/admin/get-notifications-controller.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
response.status(200).setHeader('content-type', 'application/json').send({
notifications: getRedactedAdminNotifications(),
})
}
}
78 changes: 78 additions & 0 deletions src/controllers/admin/patch-notifications-controller.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const validation = validateSchema(adminNotificationsPatchBodySchema)(request.body)
if (validation.error) {
response.status(400).setHeader('content-type', 'application/json').send({ error: 'Invalid request' })
return
}

Comment on lines +31 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 PATCH Behavior Lacks Coverage

This new write endpoint has no controller-level tests for request validation, persistence, redacted-secret round trips, or outbox failures. The added merge test covers only an existing Discord target, so cases such as a new Telegram target receiving *** are not protected against regressions. As a non-blocking improvement, add PATCH controller tests for valid partial updates, invalid bodies, new and existing secret-bearing targets, and enqueue failures.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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(),
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { GetAdminNotificationsController } from '../../controllers/admin/get-notifications-controller'
import { IController } from '../../@types/controllers'

export const createGetAdminNotificationsController = (): IController => {
return new GetAdminNotificationsController()
}
Original file line number Diff line number Diff line change
@@ -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)
}
19 changes: 16 additions & 3 deletions src/repositories/notification-delivery-log-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,24 @@ export class NotificationDeliveryLogRepository implements INotificationDeliveryL
.pluck('target_id')
}

public async findRecent(limit = 50, client: DatabaseClient = this.dbClient): Promise<NotificationDeliveryLogEntry[]> {
const rows = await client<DBNotificationDeliveryLogEntry>('notification_delivery_log')
public async findRecent(
limit = 50,
filters?: { status?: NotificationDeliveryStatus; eventType?: string },
client: DatabaseClient = this.dbClient,
): Promise<NotificationDeliveryLogEntry[]> {
let query = client<DBNotificationDeliveryLogEntry>('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)
}
Expand Down
16 changes: 16 additions & 0 deletions src/routes/admin/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
Expand Down
37 changes: 37 additions & 0 deletions src/schemas/admin-notifications-schema.ts
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 2 additions & 1 deletion src/services/operator-notification-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
70 changes: 70 additions & 0 deletions src/utils/admin-notifications-settings.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
Comment on lines +41 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Placeholder Becomes Telegram Token

When a new or renamed Telegram target sends the redacted *** value, this code restores the secret only if an existing target has the same ID. Otherwise, it saves *** as the real bot token because validation only checks that the token is non-empty. The update succeeds, but later Telegram test and notification deliveries fail with unusable credentials. Reject the placeholder when there is no matching stored secret.


return merged
}

export const mergeAdminNotificationsPatch = (
current: AdminNotificationsSettings,
patch: Partial<AdminNotificationsSettings>,
): 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
}
48 changes: 48 additions & 0 deletions test/unit/controllers/admin/get-notifications-controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading