@better-forge/notify 0.0.0-stage → 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 -0
- package/README.md +10 -2
- package/dist/alertPolicy.d.ts +41 -0
- package/dist/alertPolicy.d.ts.map +1 -0
- package/dist/alertPolicy.js +137 -0
- package/dist/alertPolicy.js.map +1 -0
- package/dist/channelInput.d.ts +34 -0
- package/dist/channelInput.d.ts.map +1 -0
- package/dist/channelInput.js +156 -0
- package/dist/channelInput.js.map +1 -0
- package/dist/channelSecrets.d.ts +8 -0
- package/dist/channelSecrets.d.ts.map +1 -0
- package/dist/channelSecrets.js +17 -0
- package/dist/channelSecrets.js.map +1 -0
- package/dist/engine.d.ts +68 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +314 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations.d.ts +907 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +86 -0
- package/dist/migrations.js.map +1 -0
- package/dist/notice.d.ts +55 -0
- package/dist/notice.d.ts.map +1 -0
- package/dist/notice.js +25 -0
- package/dist/notice.js.map +1 -0
- package/dist/routes.d.ts +74 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +302 -0
- package/dist/routes.js.map +1 -0
- package/dist/tables.d.ts +919 -0
- package/dist/tables.d.ts.map +1 -0
- package/dist/tables.js +68 -0
- package/dist/tables.js.map +1 -0
- package/dist/template.d.ts +35 -0
- package/dist/template.d.ts.map +1 -0
- package/dist/template.js +72 -0
- package/dist/template.js.map +1 -0
- package/dist/transports.d.ts +50 -0
- package/dist/transports.d.ts.map +1 -0
- package/dist/transports.js +274 -0
- package/dist/transports.js.map +1 -0
- package/dist/worker.d.ts +25 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +38 -0
- package/dist/worker.js.map +1 -0
- package/package.json +57 -4
- package/src/alertPolicy.ts +149 -0
- package/src/channelInput.ts +153 -0
- package/src/channelSecrets.ts +20 -0
- package/src/engine.ts +423 -0
- package/src/index.ts +16 -0
- package/src/migrations.ts +88 -0
- package/src/notice.ts +68 -0
- package/src/routes.ts +362 -0
- package/src/tables.ts +96 -0
- package/src/template.ts +76 -0
- package/src/transports.ts +351 -0
- 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>
|
package/src/template.ts
ADDED
|
@@ -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]
|