@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/LICENSE +21 -21
- package/README.md +11 -11
- package/dist/migrations.js +59 -59
- package/package.json +59 -59
- package/src/alertPolicy.ts +149 -149
- package/src/channelInput.ts +153 -153
- package/src/channelSecrets.ts +20 -20
- package/src/engine.ts +423 -423
- package/src/index.ts +16 -16
- package/src/migrations.ts +88 -88
- package/src/notice.ts +68 -68
- package/src/routes.ts +362 -362
- package/src/tables.ts +96 -96
- package/src/template.ts +76 -76
- package/src/transports.ts +351 -351
- package/src/worker.ts +58 -58
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
|
+
}
|