@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/tables.ts
CHANGED
|
@@ -1,96 +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>
|
|
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
CHANGED
|
@@ -1,76 +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]
|
|
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]
|