@better-forge/notify 0.0.0-stage → 0.1.0

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +11 -3
  3. package/dist/alertPolicy.d.ts +41 -0
  4. package/dist/alertPolicy.d.ts.map +1 -0
  5. package/dist/alertPolicy.js +137 -0
  6. package/dist/alertPolicy.js.map +1 -0
  7. package/dist/channelInput.d.ts +34 -0
  8. package/dist/channelInput.d.ts.map +1 -0
  9. package/dist/channelInput.js +156 -0
  10. package/dist/channelInput.js.map +1 -0
  11. package/dist/channelSecrets.d.ts +8 -0
  12. package/dist/channelSecrets.d.ts.map +1 -0
  13. package/dist/channelSecrets.js +17 -0
  14. package/dist/channelSecrets.js.map +1 -0
  15. package/dist/engine.d.ts +68 -0
  16. package/dist/engine.d.ts.map +1 -0
  17. package/dist/engine.js +314 -0
  18. package/dist/engine.js.map +1 -0
  19. package/dist/index.d.ts +12 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +12 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/migrations.d.ts +907 -0
  24. package/dist/migrations.d.ts.map +1 -0
  25. package/dist/migrations.js +86 -0
  26. package/dist/migrations.js.map +1 -0
  27. package/dist/notice.d.ts +55 -0
  28. package/dist/notice.d.ts.map +1 -0
  29. package/dist/notice.js +25 -0
  30. package/dist/notice.js.map +1 -0
  31. package/dist/routes.d.ts +74 -0
  32. package/dist/routes.d.ts.map +1 -0
  33. package/dist/routes.js +302 -0
  34. package/dist/routes.js.map +1 -0
  35. package/dist/tables.d.ts +919 -0
  36. package/dist/tables.d.ts.map +1 -0
  37. package/dist/tables.js +68 -0
  38. package/dist/tables.js.map +1 -0
  39. package/dist/template.d.ts +35 -0
  40. package/dist/template.d.ts.map +1 -0
  41. package/dist/template.js +72 -0
  42. package/dist/template.js.map +1 -0
  43. package/dist/transports.d.ts +50 -0
  44. package/dist/transports.d.ts.map +1 -0
  45. package/dist/transports.js +274 -0
  46. package/dist/transports.js.map +1 -0
  47. package/dist/worker.d.ts +25 -0
  48. package/dist/worker.d.ts.map +1 -0
  49. package/dist/worker.js +38 -0
  50. package/dist/worker.js.map +1 -0
  51. package/package.json +59 -6
  52. package/src/alertPolicy.ts +149 -0
  53. package/src/channelInput.ts +153 -0
  54. package/src/channelSecrets.ts +20 -0
  55. package/src/engine.ts +423 -0
  56. package/src/index.ts +16 -0
  57. package/src/migrations.ts +88 -0
  58. package/src/notice.ts +68 -0
  59. package/src/routes.ts +362 -0
  60. package/src/tables.ts +96 -0
  61. package/src/template.ts +76 -0
  62. package/src/transports.ts +351 -0
  63. package/src/worker.ts +58 -0
package/src/routes.ts ADDED
@@ -0,0 +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
+ }
package/src/tables.ts ADDED
@@ -0,0 +1,96 @@
1
+ import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
2
+ import { sealedText, type SealedText } from '@better-forge/vault'
3
+
4
+ /**
5
+ * How the application names what the notification tables store about it. The tables of an existing installation keep
6
+ * the column names they were created with, so the names are passed in instead of being fixed here.
7
+ */
8
+ export interface NotifyTableOptions {
9
+ /** Column of a delivery that holds the id of the thing the notification is about (it may be empty). */
10
+ sourceIdColumn: string
11
+ /** Column of a delivery that holds the name of that thing. */
12
+ sourceNameColumn: string
13
+ /** Default sender name of the SMTP settings. */
14
+ smtpFromName: string
15
+ /** Creates the stable `key` of a channel that is inserted without one. */
16
+ newKey: () => string
17
+ /**
18
+ * The text columns that hold credentials: a channel's configuration (JSON) and the SMTP password. Build them with
19
+ * `sealedText` from the codecs that encrypt the secrets; without them the values are stored as they are.
20
+ */
21
+ sealed?: { channelConfig: SealedText; smtpPassword: SealedText }
22
+ }
23
+
24
+ const plain = sealedText({ seal: (value) => value, open: (value) => value })
25
+
26
+ export function defineNotifyTables(options: NotifyTableOptions) {
27
+ const sealed = options.sealed ?? { channelConfig: plain, smtpPassword: plain }
28
+
29
+ const notificationChannels = sqliteTable('notification_channels', {
30
+ id: integer('id').primaryKey({ autoIncrement: true }),
31
+ name: text('name').notNull(),
32
+ type: text('type').notNull(), // 'email' | 'webhook'
33
+ config: sealed.channelConfig('config').notNull().default('{}'), // JSON
34
+ enabled: integer('enabled').notNull().default(1),
35
+ notifyOnRecovery: integer('notify_on_recovery').notNull().default(0),
36
+ createdAt: integer('created_at').notNull(),
37
+ updatedAt: integer('updated_at').notNull(),
38
+ // Additive columns (added via ALTER TABLE, must stay at end for sqlite-proxy position mapping)
39
+ alertPolicy: text('alert_policy').notNull().default('{}'), // JSON alert policy
40
+ /** Stable identifier for config-as-code and API clients; unlike `id` it survives export/import. */
41
+ key: text('key').notNull().unique().$defaultFn(options.newKey),
42
+ })
43
+
44
+ const notificationDeliveries = sqliteTable('notification_deliveries', {
45
+ id: integer('id').primaryKey({ autoIncrement: true }),
46
+ channelId: integer('channel_id').notNull(),
47
+ channelName: text('channel_name').notNull(),
48
+ channelType: text('channel_type').notNull(),
49
+ sourceId: integer(options.sourceIdColumn),
50
+ sourceName: text(options.sourceNameColumn).notNull(),
51
+ eventType: text('event_type').notNull(),
52
+ status: text('status').notNull().default('pending'),
53
+ targetStatus: text('target_status').notNull(),
54
+ previousStatus: text('previous_status').notNull(),
55
+ variables: text('variables').notNull(),
56
+ attemptCount: integer('attempt_count').notNull().default(0),
57
+ maxAttempts: integer('max_attempts').notNull().default(3),
58
+ nextAttemptAt: integer('next_attempt_at'),
59
+ lastAttemptAt: integer('last_attempt_at'),
60
+ deliveredAt: integer('delivered_at'),
61
+ lastError: text('last_error'),
62
+ createdAt: integer('created_at').notNull(),
63
+ updatedAt: integer('updated_at').notNull(),
64
+ // Additive columns (added via ALTER TABLE, must stay at end for sqlite-proxy position mapping)
65
+ suppressionReason: text('suppression_reason'),
66
+ groupKey: text('group_key'),
67
+ })
68
+
69
+ const notificationDeliveryAttempts = sqliteTable('notification_delivery_attempts', {
70
+ id: integer('id').primaryKey({ autoIncrement: true }),
71
+ deliveryId: integer('delivery_id').notNull(),
72
+ attemptNumber: integer('attempt_number').notNull(),
73
+ status: text('status').notNull(),
74
+ error: text('error'),
75
+ startedAt: integer('started_at').notNull(),
76
+ completedAt: integer('completed_at').notNull(),
77
+ })
78
+
79
+ const smtpSettings = sqliteTable('smtp_settings', {
80
+ id: integer('id').primaryKey(),
81
+ host: text('host').notNull().default(''),
82
+ port: integer('port').notNull().default(587),
83
+ secure: integer('secure').notNull().default(0),
84
+ user: text('user').notNull().default(''),
85
+ password: sealed.smtpPassword('password').notNull().default(''),
86
+ fromAddress: text('from_address').notNull().default(''),
87
+ fromName: text('from_name').notNull().default(options.smtpFromName),
88
+ updatedAt: integer('updated_at').notNull(),
89
+ // Additive columns (added via ALTER TABLE, must stay at end for sqlite-proxy position mapping)
90
+ vaultConfig: text('vault_config'), // JSON vault reference | null
91
+ })
92
+
93
+ return { notificationChannels, notificationDeliveries, notificationDeliveryAttempts, smtpSettings }
94
+ }
95
+
96
+ export type NotifyTables = ReturnType<typeof defineNotifyTables>
@@ -0,0 +1,76 @@
1
+ /** Replaces `{{name}}` with its value; a name without a value stays as written. `escape` is applied to each value. */
2
+ export function substituteVars(template: string, vars: Record<string, string>, escape?: (value: string) => string): string {
3
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => {
4
+ const value = vars[key]
5
+ return value === undefined ? `{{${key}}}` : escape ? escape(value) : value
6
+ })
7
+ }
8
+
9
+ /** Escapes a value for use inside a JSON string literal in a template. */
10
+ export function escapeJsonString(value: string): string {
11
+ return JSON.stringify(value).slice(1, -1)
12
+ }
13
+
14
+ /**
15
+ * Encodes a value as one piece of a path or query. A value that is only dots would be a dot segment (`..` climbs a level, and
16
+ * so does its `%2E` form), so its dots are encoded once more and arrive as the text `%2E`.
17
+ */
18
+ export function encodeUrlPart(value: string): string {
19
+ const text = withoutLoneSurrogates(value)
20
+ return /^\.{1,2}$/.test(text) ? text.replace(/\./g, '%252E') : encodeURIComponent(text)
21
+ }
22
+
23
+ /** `encodeURIComponent` throws on a lone surrogate; it becomes U+FFFD, as in any other encoding of the text. */
24
+ function withoutLoneSurrogates(value: string): string {
25
+ let out = ''
26
+ for (const char of value) {
27
+ const code = char.codePointAt(0)!
28
+ out += code >= 0xd800 && code <= 0xdfff ? String.fromCharCode(0xfffd) : char
29
+ }
30
+ return out
31
+ }
32
+
33
+ /** Whether a placeholder sits in the scheme, host or port of a URL, where it would choose the address the request goes to. */
34
+ export function placeholderInAuthority(url: string): boolean {
35
+ if (!url.includes('{{')) return false
36
+ // Asked of the URL parser itself, which skips extra slashes (`https:///{{x}}/`) and reads `\` as `/`: a placeholder is
37
+ // in the authority when two different values for it lead to two different hosts, or to no valid URL at all.
38
+ const hostFor = (value: string): string | null => {
39
+ try { return new URL(url.replace(/\{\{\w+\}\}/g, value)).host } catch { return null }
40
+ }
41
+ const first = hostFor('aaaa')
42
+ return first === null || first !== hostFor('bbbb')
43
+ }
44
+
45
+ /** Drops line breaks and NUL from a value that goes into a header, where they would split or reject the request. */
46
+ export function stripLineBreaks(value: string): string {
47
+ return value.replace(/[\r\n\0]/g, '')
48
+ }
49
+
50
+ /**
51
+ * The request a webhook configuration describes once its {{variables}} are filled in. A value is one piece of a
52
+ * path or query in the URL (so it cannot add a segment, a parameter or a host), a JSON string in the body and a single
53
+ * line in a header.
54
+ */
55
+ export function buildWebhookRequest(
56
+ config: { url: string; method: string; headers?: Record<string, string>; body?: string },
57
+ vars: Record<string, string>,
58
+ ): { url: string; init: { method: string; headers: Record<string, string>; body?: string } } {
59
+ // A value is text for a path or query; in the host it would let whoever controls the value choose where the request goes.
60
+ if (placeholderInAuthority(config.url)) throw new Error('A placeholder cannot be part of the host of the URL')
61
+ const url = substituteVars(config.url, vars, encodeUrlPart)
62
+ const body = config.body ? substituteVars(config.body, vars, escapeJsonString) : undefined
63
+
64
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' }
65
+ if (config.headers) {
66
+ for (const [k, v] of Object.entries(config.headers)) {
67
+ headers[k] = substituteVars(v, vars, stripLineBreaks)
68
+ }
69
+ }
70
+
71
+ return { url, init: { method: config.method ?? 'POST', headers, ...(body !== undefined ? { body } : {}) } }
72
+ }
73
+
74
+ /** How often a delivery is tried before it is given up on, and how long to wait before each retry. */
75
+ export const MAX_DELIVERY_ATTEMPTS = 3
76
+ export const RETRY_DELAYS_MS = [60_000, 5 * 60_000]