@better-forge/notify 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/routes.ts CHANGED
@@ -1,362 +1,362 @@
1
- import type { FastifyInstance, FastifyRequest } from 'fastify'
2
- import { and, desc, eq, gte, lte, ne, sql } from 'drizzle-orm'
3
- import { parsePagination, type Audit } from '@better-forge/audit'
4
- import { tokenAllows, VAULT_USE_SCOPE, type NotificationChannelType } from '@better-forge/contracts'
5
- import type { SqliteDatabase } from '@better-forge/db'
6
- import { canonical, type RestoreResult } from '@better-forge/vault'
7
- import { createAlertPolicy } from './alertPolicy.js'
8
- import { CHANNEL_TYPES, channelConfigError } from './channelInput.js'
9
- import type { NotifyTables } from './tables.js'
10
-
11
- /** Result of choosing a key for a new channel: the key, or the HTTP status and message that refuse it. */
12
- export type KeyResult = { key: string } | { status: 400 | 409; error: string }
13
-
14
- export interface NotifyRoutesOptions {
15
- db: SqliteDatabase
16
- tables: NotifyTables
17
- audit: Audit
18
- /** The signed-in caller of a request (the routes sit behind the application's own guards). */
19
- identity: (req: FastifyRequest) => { userId: number; email: string; apiToken?: { scopes: readonly string[] } }
20
-
21
- /** Stored name of the grouping threshold in a channel's alert policy; it is also the name the API uses. */
22
- minSourcesKey: string
23
- /** What the application calls the things notifications are about, as the API shows it. */
24
- wire: {
25
- /** Field and query parameter that carry the id of the thing a delivery is about. */
26
- sourceId: string
27
- /** Field that carries its name. */
28
- sourceName: string
29
- /** Plural, as written in the audit text of a grouping rule. */
30
- sourcePlural: string
31
- }
32
- /** Sender name the SMTP settings show before they are saved. */
33
- defaultFromName: string
34
-
35
- /** The event types whose history this caller may not read (they are left out of the list and answer 404). */
36
- hiddenEventTypes: (req: FastifyRequest) => readonly string[]
37
- /** The secrets of a channel's configuration: masked on the way out, restored when a mask comes back. */
38
- secrets: {
39
- mask: (config: unknown) => unknown
40
- restore: (incoming: unknown, stored: unknown) => RestoreResult
41
- }
42
- /** Keeps a token that may not use vault secrets away from channels that reference one. */
43
- vaultUse: {
44
- refused: string
45
- problem: (token: { scopes: readonly string[] } | undefined, incoming: unknown, stored: unknown) => string | null
46
- }
47
- /** Chooses the key of a new channel (the requested one if it is free, else one made from the name). */
48
- resolveKey: (name: string, requested: unknown, ownerOf: (key: string) => Promise<number | undefined>) => Promise<KeyResult>
49
-
50
- /** Called before a channel is deleted, to remove whatever links the application keeps to it. */
51
- onChannelDeleted: (channelId: number) => Promise<void>
52
- /** Retries a failed delivery. */
53
- retryDelivery: (deliveryId: number) => Promise<boolean>
54
- /** Sends a test notification through a channel. */
55
- testChannel: (channelId: number) => Promise<{ ok: boolean; error?: string }>
56
- /** Sends a test mail through the SMTP settings. */
57
- testSmtp: (to: string) => Promise<{ ok: boolean; error?: string }>
58
- }
59
-
60
- const SMTP_PASSWORD_MASK = '••••••••'
61
-
62
- const isConfigObject = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value)
63
-
64
- /**
65
- * The admin routes of the notifications: delivery history and retry, channel management and SMTP settings. A plain
66
- * plugin, so the hooks of the scope it is registered in apply to it.
67
- */
68
- export function createNotifyRoutes(options: NotifyRoutesOptions) {
69
- const { db, audit, wire, identity, secrets, vaultUse } = options
70
- const { notificationChannels, notificationDeliveries, notificationDeliveryAttempts, smtpSettings } = options.tables
71
- const policy = createAlertPolicy({ minSourcesKey: options.minSourcesKey })
72
- const parseAlertPolicy = (stored: string) => policy.toWire(policy.parseAlertPolicy(stored)) as Record<string, any> // eslint-disable-line @typescript-eslint/no-explicit-any
73
- const normalizeAlertPolicy = (input: unknown) => policy.toWire(policy.normalizeAlertPolicy(input))
74
-
75
- type ChannelRow = typeof notificationChannels.$inferSelect
76
-
77
- /** A delivery as the API shows it: what it is about under the application's names, every other key where it was. */
78
- function wireDelivery<T extends object>(row: T): Record<string, unknown> {
79
- return Object.fromEntries(Object.entries(row).map(([key, value]) => [
80
- key === 'sourceId' ? wire.sourceId : key === 'sourceName' ? wire.sourceName : key, value,
81
- ]))
82
- }
83
-
84
- /**
85
- * Channels leave the API with both JSON columns expanded, the policy filled in with defaults and secrets masked.
86
- * The form sends a mask back when the secret was not edited, and the stored value is kept, as long as the channel
87
- * keeps its type.
88
- */
89
- function parseChannel(r: ChannelRow) {
90
- return { ...r, config: secrets.mask(JSON.parse(r.config)), alertPolicy: parseAlertPolicy(r.alertPolicy) }
91
- }
92
-
93
- /** Flat, auditable view of the hygiene settings - nested JSON would produce useless diffs. */
94
- function policyAuditFields(r: ChannelRow): Record<string, unknown> {
95
- const p = parseAlertPolicy(r.alertPolicy)
96
- return {
97
- quietHours: p.quietHours.enabled ? `${p.quietHours.start}–${p.quietHours.end} ${p.quietHours.timezone} (${p.quietHours.mode})` : 'off',
98
- throttle: p.throttle.enabled ? `${p.throttle.maxAlerts} alerts / ${p.throttle.windowMinutes} min` : 'off',
99
- grouping: p.grouping.enabled ? `${p.grouping[options.minSourcesKey]}+ ${wire.sourcePlural} / ${p.grouping.windowSeconds} s` : 'off',
100
- }
101
- }
102
-
103
- const channelKeyOwner = async (key: string) =>
104
- (await db.select({ id: notificationChannels.id }).from(notificationChannels).where(eq(notificationChannels.key, key)))[0]?.id
105
-
106
- return async function notifyRoutes(app: FastifyInstance) {
107
- const actor = (req: FastifyRequest) => audit.actor(identity(req))
108
-
109
- app.get<{ Querystring: Record<string, string | undefined> }>('/deliveries', async (req) => {
110
- const { page, limit, offset } = parsePagination(req.query, { defaultLimit: 25, maxLimit: 100 })
111
- const query = req.query
112
- const conditions = []
113
- if (query['status']) conditions.push(eq(notificationDeliveries.status, query['status']))
114
- if (query['channelId']) conditions.push(eq(notificationDeliveries.channelId, Number(query['channelId'])))
115
- if (query['channelType']) conditions.push(eq(notificationDeliveries.channelType, query['channelType']))
116
- if (query[wire.sourceId]) conditions.push(eq(notificationDeliveries.sourceId, Number(query[wire.sourceId])))
117
- if (query['eventType']) conditions.push(eq(notificationDeliveries.eventType, query['eventType']))
118
- if (query['from']) conditions.push(gte(notificationDeliveries.createdAt, Number(query['from'])))
119
- if (query['to']) conditions.push(lte(notificationDeliveries.createdAt, Number(query['to'])))
120
- for (const eventType of options.hiddenEventTypes(req)) conditions.push(ne(notificationDeliveries.eventType, eventType))
121
- const where = conditions.length ? and(...conditions) : undefined
122
- const [rows, count] = await Promise.all([
123
- db.select({
124
- id: notificationDeliveries.id, channelId: notificationDeliveries.channelId,
125
- channelName: notificationDeliveries.channelName, channelType: notificationDeliveries.channelType,
126
- sourceId: notificationDeliveries.sourceId, sourceName: notificationDeliveries.sourceName,
127
- eventType: notificationDeliveries.eventType, status: notificationDeliveries.status,
128
- targetStatus: notificationDeliveries.targetStatus, previousStatus: notificationDeliveries.previousStatus,
129
- attemptCount: notificationDeliveries.attemptCount, maxAttempts: notificationDeliveries.maxAttempts,
130
- nextAttemptAt: notificationDeliveries.nextAttemptAt, lastAttemptAt: notificationDeliveries.lastAttemptAt,
131
- deliveredAt: notificationDeliveries.deliveredAt, lastError: notificationDeliveries.lastError,
132
- suppressionReason: notificationDeliveries.suppressionReason, groupKey: notificationDeliveries.groupKey,
133
- createdAt: notificationDeliveries.createdAt, updatedAt: notificationDeliveries.updatedAt,
134
- }).from(notificationDeliveries).where(where).orderBy(desc(notificationDeliveries.createdAt)).limit(limit).offset(offset),
135
- db.select({ count: sql<number>`count(*)` }).from(notificationDeliveries).where(where),
136
- ])
137
- const total = count[0]?.count ?? 0
138
- return { deliveries: rows.map(wireDelivery), total, page, limit, pages: Math.ceil(total / limit) }
139
- })
140
-
141
- app.get<{ Params: { id: string } }>('/deliveries/:id', async (req, reply) => {
142
- const id = Number(req.params.id)
143
- const delivery = (await db.select().from(notificationDeliveries).where(eq(notificationDeliveries.id, id)))[0]
144
- if (!delivery || options.hiddenEventTypes(req).includes(delivery.eventType)) return reply.code(404).send({ error: 'Delivery not found' })
145
- const attempts = await db.select().from(notificationDeliveryAttempts)
146
- .where(eq(notificationDeliveryAttempts.deliveryId, id)).orderBy(desc(notificationDeliveryAttempts.attemptNumber))
147
- return { ...wireDelivery(delivery), variables: undefined, attempts }
148
- })
149
-
150
- app.post<{ Params: { id: string } }>('/deliveries/:id/retry', async (req, reply) => {
151
- const id = Number(req.params.id)
152
- const existing = (await db.select().from(notificationDeliveries).where(eq(notificationDeliveries.id, id)))[0]
153
- if (!existing || options.hiddenEventTypes(req).includes(existing.eventType)) return reply.code(404).send({ error: 'Delivery not found' })
154
- if (existing.status !== 'failed') return reply.code(409).send({ error: 'Only failed deliveries can be retried' })
155
- await options.retryDelivery(id)
156
- await audit.write(actor(req), 'update', 'notification_delivery', id, `${existing.channelName} · ${existing.sourceName}`, {
157
- manualRetry: { from: false, to: true },
158
- })
159
- const row = (await db.select().from(notificationDeliveries).where(eq(notificationDeliveries.id, id)))[0]
160
- // The row may be gone already (the retention purge); the answer is then empty, as it has always been.
161
- return row && wireDelivery(row)
162
- })
163
-
164
- // ── Channels CRUD ──────────────────────────────────────────────────────
165
-
166
- app.get('/channels', async () => {
167
- const rows = await db.select().from(notificationChannels)
168
- return rows.map(parseChannel)
169
- })
170
-
171
- app.post<{ Body: {
172
- name: string; key?: string; type: string
173
- config: unknown; enabled?: number; notifyOnRecovery?: number; alertPolicy?: unknown
174
- } }>('/channels', async (req, reply) => {
175
- if (typeof req.body?.name !== 'string' || !req.body.name.trim()) return reply.code(400).send({ error: 'Name is required' })
176
- if (!CHANNEL_TYPES.includes(req.body.type as NotificationChannelType)) {
177
- return reply.code(400).send({ error: `Type must be one of: ${CHANNEL_TYPES.join(', ')}` })
178
- }
179
- if (req.body.config !== undefined && req.body.config !== null && !isConfigObject(req.body.config)) {
180
- return reply.code(400).send({ error: 'config must be an object' })
181
- }
182
- const restored = secrets.restore(req.body.config ?? {}, undefined)
183
- if ('error' in restored) return reply.code(400).send({ error: restored.error })
184
- const vaultProblem = vaultUse.problem(identity(req).apiToken, restored.config, undefined)
185
- if (vaultProblem) return reply.code(403).send({ error: vaultProblem })
186
- const configProblem = channelConfigError(req.body.type, restored.config)
187
- if (configProblem) return reply.code(400).send({ error: configProblem })
188
- const resolvedKey = await options.resolveKey(req.body.name, req.body.key, channelKeyOwner)
189
- if ('error' in resolvedKey) return reply.code(resolvedKey.status).send({ error: resolvedKey.error })
190
- const now = Date.now()
191
- const results = await db.insert(notificationChannels).values({
192
- key: resolvedKey.key,
193
- name: req.body.name,
194
- type: req.body.type,
195
- config: JSON.stringify(restored.config),
196
- enabled: req.body.enabled ?? 1,
197
- notifyOnRecovery: req.body.notifyOnRecovery ?? 0,
198
- alertPolicy: JSON.stringify(normalizeAlertPolicy(req.body.alertPolicy)),
199
- createdAt: now,
200
- updatedAt: now,
201
- }).returning()
202
- const r = results[0]!
203
- audit.write(actor(req), 'create', 'notification_channel', r.id, r.name,
204
- audit.snapshot({ name: r.name, type: r.type, enabled: r.enabled, notifyOnRecovery: r.notifyOnRecovery, ...policyAuditFields(r) }))
205
- return parseChannel(r)
206
- })
207
-
208
- app.get<{ Params: { id: string } }>('/channels/:id', async (req, reply) => {
209
- const r = (await db.select().from(notificationChannels).where(eq(notificationChannels.id, Number(req.params.id))))[0]
210
- if (!r) return reply.code(404).send({ error: 'Not found' })
211
- return parseChannel(r)
212
- })
213
-
214
- app.patch<{ Params: { id: string }; Body: Partial<{
215
- name: string; key: string; type: string; config: unknown; enabled: number; notifyOnRecovery: number; alertPolicy: unknown
216
- }> }>('/channels/:id', async (req, reply) => {
217
- const id = Number(req.params.id)
218
- const existing = (await db.select().from(notificationChannels).where(eq(notificationChannels.id, id)))[0]
219
- if (!existing) return reply.code(404).send({ error: 'Not found' })
220
-
221
- if (req.body.type !== undefined && !CHANNEL_TYPES.includes(req.body.type as NotificationChannelType)) {
222
- return reply.code(400).send({ error: `Type must be one of: ${CHANNEL_TYPES.join(', ')}` })
223
- }
224
- // The stored config belongs to the old type; keeping it under another one would also change what is masked.
225
- if (req.body.type !== undefined && req.body.type !== existing.type && req.body.config === undefined) {
226
- return reply.code(400).send({ error: 'Changing the type needs a new config in the same request' })
227
- }
228
- if (req.body.config !== undefined && !isConfigObject(req.body.config)) {
229
- return reply.code(400).send({ error: 'config must be an object' })
230
- }
231
- if (req.body.key !== undefined) return reply.code(400).send({ error: 'The key is set when the object is created and cannot be changed' })
232
- const effectiveType = req.body.type ?? existing.type
233
- let config: unknown
234
- if (req.body.config !== undefined) {
235
- const stored = effectiveType === existing.type ? JSON.parse(existing.config) : undefined
236
- const restored = secrets.restore(req.body.config, stored)
237
- if ('error' in restored) return reply.code(400).send({ error: restored.error })
238
- const vaultProblem = vaultUse.problem(identity(req).apiToken, restored.config, stored)
239
- if (vaultProblem) return reply.code(403).send({ error: vaultProblem })
240
- config = restored.config
241
- }
242
- if (config !== undefined || req.body.type !== undefined) {
243
- const configProblem = channelConfigError(effectiveType, config ?? JSON.parse(existing.config))
244
- if (configProblem) return reply.code(400).send({ error: configProblem })
245
- }
246
-
247
- const updates: Partial<typeof notificationChannels.$inferInsert> = { updatedAt: Date.now() }
248
- if (req.body.name !== undefined) updates.name = req.body.name
249
- if (req.body.type !== undefined) updates.type = req.body.type
250
- if (req.body.config !== undefined) updates.config = JSON.stringify(config)
251
- if (req.body.enabled !== undefined) updates.enabled = req.body.enabled
252
- if (req.body.notifyOnRecovery !== undefined) updates.notifyOnRecovery = req.body.notifyOnRecovery
253
- if (req.body.alertPolicy !== undefined) updates.alertPolicy = JSON.stringify(normalizeAlertPolicy(req.body.alertPolicy))
254
-
255
- const results = await db.update(notificationChannels).set(updates).where(eq(notificationChannels.id, id)).returning()
256
- const r = results[0]!
257
- const before = { name: existing.name, type: existing.type, enabled: existing.enabled, notifyOnRecovery: existing.notifyOnRecovery, ...policyAuditFields(existing) } as Record<string, unknown>
258
- const after = { name: r.name, type: r.type, enabled: r.enabled, notifyOnRecovery: r.notifyOnRecovery, ...policyAuditFields(r) } as Record<string, unknown>
259
- const diff = audit.diffObjects(before, after)
260
- if (req.body.config !== undefined) diff['config'] = { from: '[previous config]', to: '[updated config]' }
261
- if (Object.keys(diff).length) audit.write(actor(req), 'update', 'notification_channel', id, existing.name, diff)
262
- return parseChannel(r)
263
- })
264
-
265
- app.delete<{ Params: { id: string } }>('/channels/:id', async (req, reply) => {
266
- const id = Number(req.params.id)
267
- const existing = (await db.select().from(notificationChannels).where(eq(notificationChannels.id, id)))[0]
268
- await options.onChannelDeleted(id)
269
- await db.delete(notificationChannels).where(eq(notificationChannels.id, id))
270
- if (existing) {
271
- audit.write(actor(req), 'delete', 'notification_channel', id, existing.name,
272
- audit.snapshot({ name: existing.name, type: existing.type }))
273
- }
274
- return reply.code(204).send()
275
- })
276
-
277
- app.post<{ Params: { id: string } }>('/channels/:id/test', async (req, reply) => {
278
- const result = await options.testChannel(Number(req.params.id))
279
- if (!result.ok) return reply.code(422).send({ error: result.error })
280
- return { ok: true }
281
- })
282
-
283
- // ── SMTP Settings ──────────────────────────────────────────────────────
284
-
285
- app.get('/smtp', async () => {
286
- const row = (await db.select().from(smtpSettings))[0]
287
- if (!row) return { host: '', port: 587, secure: 0, user: '', password: '', fromAddress: '', fromName: options.defaultFromName, vault: null, updatedAt: 0 }
288
- return {
289
- ...row,
290
- password: row.password ? SMTP_PASSWORD_MASK : '',
291
- vault: row.vaultConfig ? JSON.parse(row.vaultConfig) : null,
292
- }
293
- })
294
-
295
- app.put<{ Body: {
296
- host: string; port: number; secure: number
297
- user: string; password?: string; fromAddress: string; fromName: string
298
- vault?: { vaultId: number; secretId: number; fieldMapping?: Record<string, string> } | null
299
- } }>('/smtp', async (req, reply) => {
300
- const now = Date.now()
301
- const existing = (await db.select().from(smtpSettings))[0]
302
-
303
- // A vault secret is sent to the SMTP server: a token without the permission to use vault secrets may keep the
304
- // reference and server that are saved, but not set or change them.
305
- const token = identity(req).apiToken
306
- if (token && req.body.vault && !tokenAllows(token.scopes, VAULT_USE_SCOPE)) {
307
- const saved = existing?.vaultConfig ?? null
308
- const sameReference = saved !== null && canonical(JSON.parse(saved)) === canonical(req.body.vault)
309
- const sameServer = !!existing && req.body.host === existing.host && Number(req.body.port) === existing.port
310
- const sameTransport = !!existing && Number(req.body.secure) === Number(existing.secure) && (req.body.user ?? '') === existing.user
311
- if (!sameReference || !sameServer || !sameTransport) return reply.code(403).send({ error: vaultUse.refused })
312
- }
313
-
314
- const passwordSupplied = !!req.body.password && req.body.password !== SMTP_PASSWORD_MASK
315
- // Never forward the stored password to a different server or account: changing where it goes
316
- // requires re-entering it.
317
- if (existing?.password && !req.body.vault && !passwordSupplied && (
318
- req.body.host !== existing.host || Number(req.body.port) !== existing.port || (req.body.user ?? '') !== existing.user
319
- )) {
320
- return reply.code(400).send({ error: 'Re-enter the SMTP password when changing the host, port or username' })
321
- }
322
-
323
- const values = {
324
- host: req.body.host,
325
- port: req.body.port,
326
- secure: req.body.secure,
327
- user: req.body.vault ? '' : (req.body.user ?? ''),
328
- fromAddress: req.body.fromAddress,
329
- fromName: req.body.fromName,
330
- vaultConfig: req.body.vault ? JSON.stringify(req.body.vault) : null,
331
- updatedAt: now,
332
- }
333
-
334
- if (existing) {
335
- const password = req.body.vault
336
- ? ''
337
- : (passwordSupplied ? req.body.password! : existing.password)
338
- await db.update(smtpSettings).set({ ...values, password }).where(eq(smtpSettings.id, 1))
339
- } else {
340
- await db.insert(smtpSettings).values({ id: 1, ...values, password: req.body.vault ? '' : (req.body.password ?? '') })
341
- }
342
-
343
- const diff: Record<string, unknown> = {}
344
- if (existing) {
345
- const before = { host: existing.host, port: existing.port, secure: existing.secure, user: existing.user, fromAddress: existing.fromAddress, fromName: existing.fromName } as Record<string, unknown>
346
- const after = { host: req.body.host, port: req.body.port, secure: req.body.secure, user: req.body.user ?? '', fromAddress: req.body.fromAddress, fromName: req.body.fromName } as Record<string, unknown>
347
- Object.assign(diff, audit.diffObjects(before, after))
348
- }
349
- if (req.body.password && req.body.password !== SMTP_PASSWORD_MASK) diff['password'] = { from: '[redacted]', to: '[redacted]' }
350
- audit.write(actor(req), existing ? 'update' : 'create', 'smtp_settings', 1, 'SMTP Settings', Object.keys(diff).length ? diff : undefined)
351
- return { ok: true }
352
- })
353
-
354
- app.post<{ Body: { to: string } }>('/smtp/test', async (req, reply) => {
355
- const { to } = req.body
356
- if (!to) return reply.code(400).send({ error: 'Recipient address required' })
357
- const result = await options.testSmtp(to)
358
- if (!result.ok) return reply.code(422).send({ error: result.error })
359
- return { ok: true }
360
- })
361
- }
362
- }
1
+ import type { FastifyInstance, FastifyRequest } from 'fastify'
2
+ import { and, desc, eq, gte, lte, ne, sql } from 'drizzle-orm'
3
+ import { parsePagination, type Audit } from '@better-forge/audit'
4
+ import { tokenAllows, VAULT_USE_SCOPE, type NotificationChannelType } from '@better-forge/contracts'
5
+ import type { SqliteDatabase } from '@better-forge/db'
6
+ import { canonical, type RestoreResult } from '@better-forge/vault'
7
+ import { createAlertPolicy } from './alertPolicy.js'
8
+ import { CHANNEL_TYPES, channelConfigError } from './channelInput.js'
9
+ import type { NotifyTables } from './tables.js'
10
+
11
+ /** Result of choosing a key for a new channel: the key, or the HTTP status and message that refuse it. */
12
+ export type KeyResult = { key: string } | { status: 400 | 409; error: string }
13
+
14
+ export interface NotifyRoutesOptions {
15
+ db: SqliteDatabase
16
+ tables: NotifyTables
17
+ audit: Audit
18
+ /** The signed-in caller of a request (the routes sit behind the application's own guards). */
19
+ identity: (req: FastifyRequest) => { userId: number; email: string; apiToken?: { scopes: readonly string[] } }
20
+
21
+ /** Stored name of the grouping threshold in a channel's alert policy; it is also the name the API uses. */
22
+ minSourcesKey: string
23
+ /** What the application calls the things notifications are about, as the API shows it. */
24
+ wire: {
25
+ /** Field and query parameter that carry the id of the thing a delivery is about. */
26
+ sourceId: string
27
+ /** Field that carries its name. */
28
+ sourceName: string
29
+ /** Plural, as written in the audit text of a grouping rule. */
30
+ sourcePlural: string
31
+ }
32
+ /** Sender name the SMTP settings show before they are saved. */
33
+ defaultFromName: string
34
+
35
+ /** The event types whose history this caller may not read (they are left out of the list and answer 404). */
36
+ hiddenEventTypes: (req: FastifyRequest) => readonly string[]
37
+ /** The secrets of a channel's configuration: masked on the way out, restored when a mask comes back. */
38
+ secrets: {
39
+ mask: (config: unknown) => unknown
40
+ restore: (incoming: unknown, stored: unknown) => RestoreResult
41
+ }
42
+ /** Keeps a token that may not use vault secrets away from channels that reference one. */
43
+ vaultUse: {
44
+ refused: string
45
+ problem: (token: { scopes: readonly string[] } | undefined, incoming: unknown, stored: unknown) => string | null
46
+ }
47
+ /** Chooses the key of a new channel (the requested one if it is free, else one made from the name). */
48
+ resolveKey: (name: string, requested: unknown, ownerOf: (key: string) => Promise<number | undefined>) => Promise<KeyResult>
49
+
50
+ /** Called before a channel is deleted, to remove whatever links the application keeps to it. */
51
+ onChannelDeleted: (channelId: number) => Promise<void>
52
+ /** Retries a failed delivery. */
53
+ retryDelivery: (deliveryId: number) => Promise<boolean>
54
+ /** Sends a test notification through a channel. */
55
+ testChannel: (channelId: number) => Promise<{ ok: boolean; error?: string }>
56
+ /** Sends a test mail through the SMTP settings. */
57
+ testSmtp: (to: string) => Promise<{ ok: boolean; error?: string }>
58
+ }
59
+
60
+ const SMTP_PASSWORD_MASK = '••••••••'
61
+
62
+ const isConfigObject = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value)
63
+
64
+ /**
65
+ * The admin routes of the notifications: delivery history and retry, channel management and SMTP settings. A plain
66
+ * plugin, so the hooks of the scope it is registered in apply to it.
67
+ */
68
+ export function createNotifyRoutes(options: NotifyRoutesOptions) {
69
+ const { db, audit, wire, identity, secrets, vaultUse } = options
70
+ const { notificationChannels, notificationDeliveries, notificationDeliveryAttempts, smtpSettings } = options.tables
71
+ const policy = createAlertPolicy({ minSourcesKey: options.minSourcesKey })
72
+ const parseAlertPolicy = (stored: string) => policy.toWire(policy.parseAlertPolicy(stored)) as Record<string, any> // eslint-disable-line @typescript-eslint/no-explicit-any
73
+ const normalizeAlertPolicy = (input: unknown) => policy.toWire(policy.normalizeAlertPolicy(input))
74
+
75
+ type ChannelRow = typeof notificationChannels.$inferSelect
76
+
77
+ /** A delivery as the API shows it: what it is about under the application's names, every other key where it was. */
78
+ function wireDelivery<T extends object>(row: T): Record<string, unknown> {
79
+ return Object.fromEntries(Object.entries(row).map(([key, value]) => [
80
+ key === 'sourceId' ? wire.sourceId : key === 'sourceName' ? wire.sourceName : key, value,
81
+ ]))
82
+ }
83
+
84
+ /**
85
+ * Channels leave the API with both JSON columns expanded, the policy filled in with defaults and secrets masked.
86
+ * The form sends a mask back when the secret was not edited, and the stored value is kept, as long as the channel
87
+ * keeps its type.
88
+ */
89
+ function parseChannel(r: ChannelRow) {
90
+ return { ...r, config: secrets.mask(JSON.parse(r.config)), alertPolicy: parseAlertPolicy(r.alertPolicy) }
91
+ }
92
+
93
+ /** Flat, auditable view of the hygiene settings - nested JSON would produce useless diffs. */
94
+ function policyAuditFields(r: ChannelRow): Record<string, unknown> {
95
+ const p = parseAlertPolicy(r.alertPolicy)
96
+ return {
97
+ quietHours: p.quietHours.enabled ? `${p.quietHours.start}–${p.quietHours.end} ${p.quietHours.timezone} (${p.quietHours.mode})` : 'off',
98
+ throttle: p.throttle.enabled ? `${p.throttle.maxAlerts} alerts / ${p.throttle.windowMinutes} min` : 'off',
99
+ grouping: p.grouping.enabled ? `${p.grouping[options.minSourcesKey]}+ ${wire.sourcePlural} / ${p.grouping.windowSeconds} s` : 'off',
100
+ }
101
+ }
102
+
103
+ const channelKeyOwner = async (key: string) =>
104
+ (await db.select({ id: notificationChannels.id }).from(notificationChannels).where(eq(notificationChannels.key, key)))[0]?.id
105
+
106
+ return async function notifyRoutes(app: FastifyInstance) {
107
+ const actor = (req: FastifyRequest) => audit.actor(identity(req))
108
+
109
+ app.get<{ Querystring: Record<string, string | undefined> }>('/deliveries', async (req) => {
110
+ const { page, limit, offset } = parsePagination(req.query, { defaultLimit: 25, maxLimit: 100 })
111
+ const query = req.query
112
+ const conditions = []
113
+ if (query['status']) conditions.push(eq(notificationDeliveries.status, query['status']))
114
+ if (query['channelId']) conditions.push(eq(notificationDeliveries.channelId, Number(query['channelId'])))
115
+ if (query['channelType']) conditions.push(eq(notificationDeliveries.channelType, query['channelType']))
116
+ if (query[wire.sourceId]) conditions.push(eq(notificationDeliveries.sourceId, Number(query[wire.sourceId])))
117
+ if (query['eventType']) conditions.push(eq(notificationDeliveries.eventType, query['eventType']))
118
+ if (query['from']) conditions.push(gte(notificationDeliveries.createdAt, Number(query['from'])))
119
+ if (query['to']) conditions.push(lte(notificationDeliveries.createdAt, Number(query['to'])))
120
+ for (const eventType of options.hiddenEventTypes(req)) conditions.push(ne(notificationDeliveries.eventType, eventType))
121
+ const where = conditions.length ? and(...conditions) : undefined
122
+ const [rows, count] = await Promise.all([
123
+ db.select({
124
+ id: notificationDeliveries.id, channelId: notificationDeliveries.channelId,
125
+ channelName: notificationDeliveries.channelName, channelType: notificationDeliveries.channelType,
126
+ sourceId: notificationDeliveries.sourceId, sourceName: notificationDeliveries.sourceName,
127
+ eventType: notificationDeliveries.eventType, status: notificationDeliveries.status,
128
+ targetStatus: notificationDeliveries.targetStatus, previousStatus: notificationDeliveries.previousStatus,
129
+ attemptCount: notificationDeliveries.attemptCount, maxAttempts: notificationDeliveries.maxAttempts,
130
+ nextAttemptAt: notificationDeliveries.nextAttemptAt, lastAttemptAt: notificationDeliveries.lastAttemptAt,
131
+ deliveredAt: notificationDeliveries.deliveredAt, lastError: notificationDeliveries.lastError,
132
+ suppressionReason: notificationDeliveries.suppressionReason, groupKey: notificationDeliveries.groupKey,
133
+ createdAt: notificationDeliveries.createdAt, updatedAt: notificationDeliveries.updatedAt,
134
+ }).from(notificationDeliveries).where(where).orderBy(desc(notificationDeliveries.createdAt)).limit(limit).offset(offset),
135
+ db.select({ count: sql<number>`count(*)` }).from(notificationDeliveries).where(where),
136
+ ])
137
+ const total = count[0]?.count ?? 0
138
+ return { deliveries: rows.map(wireDelivery), total, page, limit, pages: Math.ceil(total / limit) }
139
+ })
140
+
141
+ app.get<{ Params: { id: string } }>('/deliveries/:id', async (req, reply) => {
142
+ const id = Number(req.params.id)
143
+ const delivery = (await db.select().from(notificationDeliveries).where(eq(notificationDeliveries.id, id)))[0]
144
+ if (!delivery || options.hiddenEventTypes(req).includes(delivery.eventType)) return reply.code(404).send({ error: 'Delivery not found' })
145
+ const attempts = await db.select().from(notificationDeliveryAttempts)
146
+ .where(eq(notificationDeliveryAttempts.deliveryId, id)).orderBy(desc(notificationDeliveryAttempts.attemptNumber))
147
+ return { ...wireDelivery(delivery), variables: undefined, attempts }
148
+ })
149
+
150
+ app.post<{ Params: { id: string } }>('/deliveries/:id/retry', async (req, reply) => {
151
+ const id = Number(req.params.id)
152
+ const existing = (await db.select().from(notificationDeliveries).where(eq(notificationDeliveries.id, id)))[0]
153
+ if (!existing || options.hiddenEventTypes(req).includes(existing.eventType)) return reply.code(404).send({ error: 'Delivery not found' })
154
+ if (existing.status !== 'failed') return reply.code(409).send({ error: 'Only failed deliveries can be retried' })
155
+ await options.retryDelivery(id)
156
+ await audit.write(actor(req), 'update', 'notification_delivery', id, `${existing.channelName} · ${existing.sourceName}`, {
157
+ manualRetry: { from: false, to: true },
158
+ })
159
+ const row = (await db.select().from(notificationDeliveries).where(eq(notificationDeliveries.id, id)))[0]
160
+ // The row may be gone already (the retention purge); the answer is then empty, as it has always been.
161
+ return row && wireDelivery(row)
162
+ })
163
+
164
+ // ── Channels CRUD ──────────────────────────────────────────────────────
165
+
166
+ app.get('/channels', async () => {
167
+ const rows = await db.select().from(notificationChannels)
168
+ return rows.map(parseChannel)
169
+ })
170
+
171
+ app.post<{ Body: {
172
+ name: string; key?: string; type: string
173
+ config: unknown; enabled?: number; notifyOnRecovery?: number; alertPolicy?: unknown
174
+ } }>('/channels', async (req, reply) => {
175
+ if (typeof req.body?.name !== 'string' || !req.body.name.trim()) return reply.code(400).send({ error: 'Name is required' })
176
+ if (!CHANNEL_TYPES.includes(req.body.type as NotificationChannelType)) {
177
+ return reply.code(400).send({ error: `Type must be one of: ${CHANNEL_TYPES.join(', ')}` })
178
+ }
179
+ if (req.body.config !== undefined && req.body.config !== null && !isConfigObject(req.body.config)) {
180
+ return reply.code(400).send({ error: 'config must be an object' })
181
+ }
182
+ const restored = secrets.restore(req.body.config ?? {}, undefined)
183
+ if ('error' in restored) return reply.code(400).send({ error: restored.error })
184
+ const vaultProblem = vaultUse.problem(identity(req).apiToken, restored.config, undefined)
185
+ if (vaultProblem) return reply.code(403).send({ error: vaultProblem })
186
+ const configProblem = channelConfigError(req.body.type, restored.config)
187
+ if (configProblem) return reply.code(400).send({ error: configProblem })
188
+ const resolvedKey = await options.resolveKey(req.body.name, req.body.key, channelKeyOwner)
189
+ if ('error' in resolvedKey) return reply.code(resolvedKey.status).send({ error: resolvedKey.error })
190
+ const now = Date.now()
191
+ const results = await db.insert(notificationChannels).values({
192
+ key: resolvedKey.key,
193
+ name: req.body.name,
194
+ type: req.body.type,
195
+ config: JSON.stringify(restored.config),
196
+ enabled: req.body.enabled ?? 1,
197
+ notifyOnRecovery: req.body.notifyOnRecovery ?? 0,
198
+ alertPolicy: JSON.stringify(normalizeAlertPolicy(req.body.alertPolicy)),
199
+ createdAt: now,
200
+ updatedAt: now,
201
+ }).returning()
202
+ const r = results[0]!
203
+ audit.write(actor(req), 'create', 'notification_channel', r.id, r.name,
204
+ audit.snapshot({ name: r.name, type: r.type, enabled: r.enabled, notifyOnRecovery: r.notifyOnRecovery, ...policyAuditFields(r) }))
205
+ return parseChannel(r)
206
+ })
207
+
208
+ app.get<{ Params: { id: string } }>('/channels/:id', async (req, reply) => {
209
+ const r = (await db.select().from(notificationChannels).where(eq(notificationChannels.id, Number(req.params.id))))[0]
210
+ if (!r) return reply.code(404).send({ error: 'Not found' })
211
+ return parseChannel(r)
212
+ })
213
+
214
+ app.patch<{ Params: { id: string }; Body: Partial<{
215
+ name: string; key: string; type: string; config: unknown; enabled: number; notifyOnRecovery: number; alertPolicy: unknown
216
+ }> }>('/channels/:id', async (req, reply) => {
217
+ const id = Number(req.params.id)
218
+ const existing = (await db.select().from(notificationChannels).where(eq(notificationChannels.id, id)))[0]
219
+ if (!existing) return reply.code(404).send({ error: 'Not found' })
220
+
221
+ if (req.body.type !== undefined && !CHANNEL_TYPES.includes(req.body.type as NotificationChannelType)) {
222
+ return reply.code(400).send({ error: `Type must be one of: ${CHANNEL_TYPES.join(', ')}` })
223
+ }
224
+ // The stored config belongs to the old type; keeping it under another one would also change what is masked.
225
+ if (req.body.type !== undefined && req.body.type !== existing.type && req.body.config === undefined) {
226
+ return reply.code(400).send({ error: 'Changing the type needs a new config in the same request' })
227
+ }
228
+ if (req.body.config !== undefined && !isConfigObject(req.body.config)) {
229
+ return reply.code(400).send({ error: 'config must be an object' })
230
+ }
231
+ if (req.body.key !== undefined) return reply.code(400).send({ error: 'The key is set when the object is created and cannot be changed' })
232
+ const effectiveType = req.body.type ?? existing.type
233
+ let config: unknown
234
+ if (req.body.config !== undefined) {
235
+ const stored = effectiveType === existing.type ? JSON.parse(existing.config) : undefined
236
+ const restored = secrets.restore(req.body.config, stored)
237
+ if ('error' in restored) return reply.code(400).send({ error: restored.error })
238
+ const vaultProblem = vaultUse.problem(identity(req).apiToken, restored.config, stored)
239
+ if (vaultProblem) return reply.code(403).send({ error: vaultProblem })
240
+ config = restored.config
241
+ }
242
+ if (config !== undefined || req.body.type !== undefined) {
243
+ const configProblem = channelConfigError(effectiveType, config ?? JSON.parse(existing.config))
244
+ if (configProblem) return reply.code(400).send({ error: configProblem })
245
+ }
246
+
247
+ const updates: Partial<typeof notificationChannels.$inferInsert> = { updatedAt: Date.now() }
248
+ if (req.body.name !== undefined) updates.name = req.body.name
249
+ if (req.body.type !== undefined) updates.type = req.body.type
250
+ if (req.body.config !== undefined) updates.config = JSON.stringify(config)
251
+ if (req.body.enabled !== undefined) updates.enabled = req.body.enabled
252
+ if (req.body.notifyOnRecovery !== undefined) updates.notifyOnRecovery = req.body.notifyOnRecovery
253
+ if (req.body.alertPolicy !== undefined) updates.alertPolicy = JSON.stringify(normalizeAlertPolicy(req.body.alertPolicy))
254
+
255
+ const results = await db.update(notificationChannels).set(updates).where(eq(notificationChannels.id, id)).returning()
256
+ const r = results[0]!
257
+ const before = { name: existing.name, type: existing.type, enabled: existing.enabled, notifyOnRecovery: existing.notifyOnRecovery, ...policyAuditFields(existing) } as Record<string, unknown>
258
+ const after = { name: r.name, type: r.type, enabled: r.enabled, notifyOnRecovery: r.notifyOnRecovery, ...policyAuditFields(r) } as Record<string, unknown>
259
+ const diff = audit.diffObjects(before, after)
260
+ if (req.body.config !== undefined) diff['config'] = { from: '[previous config]', to: '[updated config]' }
261
+ if (Object.keys(diff).length) audit.write(actor(req), 'update', 'notification_channel', id, existing.name, diff)
262
+ return parseChannel(r)
263
+ })
264
+
265
+ app.delete<{ Params: { id: string } }>('/channels/:id', async (req, reply) => {
266
+ const id = Number(req.params.id)
267
+ const existing = (await db.select().from(notificationChannels).where(eq(notificationChannels.id, id)))[0]
268
+ await options.onChannelDeleted(id)
269
+ await db.delete(notificationChannels).where(eq(notificationChannels.id, id))
270
+ if (existing) {
271
+ audit.write(actor(req), 'delete', 'notification_channel', id, existing.name,
272
+ audit.snapshot({ name: existing.name, type: existing.type }))
273
+ }
274
+ return reply.code(204).send()
275
+ })
276
+
277
+ app.post<{ Params: { id: string } }>('/channels/:id/test', async (req, reply) => {
278
+ const result = await options.testChannel(Number(req.params.id))
279
+ if (!result.ok) return reply.code(422).send({ error: result.error })
280
+ return { ok: true }
281
+ })
282
+
283
+ // ── SMTP Settings ──────────────────────────────────────────────────────
284
+
285
+ app.get('/smtp', async () => {
286
+ const row = (await db.select().from(smtpSettings))[0]
287
+ if (!row) return { host: '', port: 587, secure: 0, user: '', password: '', fromAddress: '', fromName: options.defaultFromName, vault: null, updatedAt: 0 }
288
+ return {
289
+ ...row,
290
+ password: row.password ? SMTP_PASSWORD_MASK : '',
291
+ vault: row.vaultConfig ? JSON.parse(row.vaultConfig) : null,
292
+ }
293
+ })
294
+
295
+ app.put<{ Body: {
296
+ host: string; port: number; secure: number
297
+ user: string; password?: string; fromAddress: string; fromName: string
298
+ vault?: { vaultId: number; secretId: number; fieldMapping?: Record<string, string> } | null
299
+ } }>('/smtp', async (req, reply) => {
300
+ const now = Date.now()
301
+ const existing = (await db.select().from(smtpSettings))[0]
302
+
303
+ // A vault secret is sent to the SMTP server: a token without the permission to use vault secrets may keep the
304
+ // reference and server that are saved, but not set or change them.
305
+ const token = identity(req).apiToken
306
+ if (token && req.body.vault && !tokenAllows(token.scopes, VAULT_USE_SCOPE)) {
307
+ const saved = existing?.vaultConfig ?? null
308
+ const sameReference = saved !== null && canonical(JSON.parse(saved)) === canonical(req.body.vault)
309
+ const sameServer = !!existing && req.body.host === existing.host && Number(req.body.port) === existing.port
310
+ const sameTransport = !!existing && Number(req.body.secure) === Number(existing.secure) && (req.body.user ?? '') === existing.user
311
+ if (!sameReference || !sameServer || !sameTransport) return reply.code(403).send({ error: vaultUse.refused })
312
+ }
313
+
314
+ const passwordSupplied = !!req.body.password && req.body.password !== SMTP_PASSWORD_MASK
315
+ // Never forward the stored password to a different server or account: changing where it goes
316
+ // requires re-entering it.
317
+ if (existing?.password && !req.body.vault && !passwordSupplied && (
318
+ req.body.host !== existing.host || Number(req.body.port) !== existing.port || (req.body.user ?? '') !== existing.user
319
+ )) {
320
+ return reply.code(400).send({ error: 'Re-enter the SMTP password when changing the host, port or username' })
321
+ }
322
+
323
+ const values = {
324
+ host: req.body.host,
325
+ port: req.body.port,
326
+ secure: req.body.secure,
327
+ user: req.body.vault ? '' : (req.body.user ?? ''),
328
+ fromAddress: req.body.fromAddress,
329
+ fromName: req.body.fromName,
330
+ vaultConfig: req.body.vault ? JSON.stringify(req.body.vault) : null,
331
+ updatedAt: now,
332
+ }
333
+
334
+ if (existing) {
335
+ const password = req.body.vault
336
+ ? ''
337
+ : (passwordSupplied ? req.body.password! : existing.password)
338
+ await db.update(smtpSettings).set({ ...values, password }).where(eq(smtpSettings.id, 1))
339
+ } else {
340
+ await db.insert(smtpSettings).values({ id: 1, ...values, password: req.body.vault ? '' : (req.body.password ?? '') })
341
+ }
342
+
343
+ const diff: Record<string, unknown> = {}
344
+ if (existing) {
345
+ const before = { host: existing.host, port: existing.port, secure: existing.secure, user: existing.user, fromAddress: existing.fromAddress, fromName: existing.fromName } as Record<string, unknown>
346
+ const after = { host: req.body.host, port: req.body.port, secure: req.body.secure, user: req.body.user ?? '', fromAddress: req.body.fromAddress, fromName: req.body.fromName } as Record<string, unknown>
347
+ Object.assign(diff, audit.diffObjects(before, after))
348
+ }
349
+ if (req.body.password && req.body.password !== SMTP_PASSWORD_MASK) diff['password'] = { from: '[redacted]', to: '[redacted]' }
350
+ audit.write(actor(req), existing ? 'update' : 'create', 'smtp_settings', 1, 'SMTP Settings', Object.keys(diff).length ? diff : undefined)
351
+ return { ok: true }
352
+ })
353
+
354
+ app.post<{ Body: { to: string } }>('/smtp/test', async (req, reply) => {
355
+ const { to } = req.body
356
+ if (!to) return reply.code(400).send({ error: 'Recipient address required' })
357
+ const result = await options.testSmtp(to)
358
+ if (!result.ok) return reply.code(422).send({ error: result.error })
359
+ return { ok: true }
360
+ })
361
+ }
362
+ }