@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.
- package/LICENSE +21 -0
- package/README.md +11 -3
- 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 +59 -6
- 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/dist/worker.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import cron from 'node-cron';
|
|
2
|
+
/**
|
|
3
|
+
* Sends the deliveries that came due (retries and digests) every 30 seconds and purges old history daily. Does not
|
|
4
|
+
* wait for a pass to finish before the next one is due; the engine takes care of overlapping passes.
|
|
5
|
+
*/
|
|
6
|
+
export function createDeliveryWorker(options) {
|
|
7
|
+
let retryTask = null;
|
|
8
|
+
let purgeTask = null;
|
|
9
|
+
const tasks = (list) => {
|
|
10
|
+
for (const task of list)
|
|
11
|
+
task.run().catch((error) => console.error(task.failure, error));
|
|
12
|
+
};
|
|
13
|
+
function start() {
|
|
14
|
+
if (retryTask || purgeTask)
|
|
15
|
+
return;
|
|
16
|
+
retryTask = cron.schedule('*/30 * * * * *', () => {
|
|
17
|
+
tasks([
|
|
18
|
+
{ failure: '[notifier] Retry worker failed:', run: () => options.engine.processDue() },
|
|
19
|
+
...(options.retryTasks ?? []),
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
purgeTask = cron.schedule('0 3 * * *', () => {
|
|
23
|
+
tasks([
|
|
24
|
+
{ failure: '[notifier] Delivery history purge failed:', run: () => options.engine.purgeOldDeliveries() },
|
|
25
|
+
...(options.purgeTasks ?? []),
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
console.log(`${options.logPrefix ?? '[notifier]'} Retry worker started`);
|
|
29
|
+
}
|
|
30
|
+
function stop() {
|
|
31
|
+
retryTask?.destroy();
|
|
32
|
+
purgeTask?.destroy();
|
|
33
|
+
retryTask = null;
|
|
34
|
+
purgeTask = null;
|
|
35
|
+
}
|
|
36
|
+
return { start, stop };
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=worker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worker.js","sourceRoot":"","sources":["../src/worker.ts"],"names":[],"mappings":"AAAA,OAAO,IAA4B,MAAM,WAAW,CAAA;AAoBpD;;;GAGG;AACH,MAAM,UAAU,oBAAoB,CAAC,OAA8B;IACjE,IAAI,SAAS,GAAyB,IAAI,CAAA;IAC1C,IAAI,SAAS,GAAyB,IAAI,CAAA;IAE1C,MAAM,KAAK,GAAG,CAAC,IAA2B,EAAE,EAAE;QAC5C,KAAK,MAAM,IAAI,IAAI,IAAI;YAAE,IAAI,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAA;IAC1F,CAAC,CAAA;IAED,SAAS,KAAK;QACZ,IAAI,SAAS,IAAI,SAAS;YAAE,OAAM;QAClC,SAAS,GAAG,IAAI,CAAC,QAAQ,CAAC,gBAAgB,EAAE,GAAG,EAAE;YAC/C,KAAK,CAAC;gBACJ,EAAE,OAAO,EAAE,iCAAiC,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,UAAU,EAAE,EAAE;gBACtF,GAAG,CAAC,OAAO,CAAC,UAAU,IAAI,EAAE,CAAC;aAC9B,CAAC,CAAA;QACJ,CAAC,CAAC,CAAA;QACF,SAAS,GAAG,IAAI,CAAC,QAAQ,CAAC,WAAW,EAAE,GAAG,EAAE;YAC1C,KAAK,CAAC;gBACJ,EAAE,OAAO,EAAE,2CAA2C,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,kBAAkB,EAAE,EAAE;gBACxG,GAAG,CAAC,OAAO,CAAC,UAAU,IAAI,EAAE,CAAC;aAC9B,CAAC,CAAA;QACJ,CAAC,CAAC,CAAA;QACF,OAAO,CAAC,GAAG,CAAC,GAAG,OAAO,CAAC,SAAS,IAAI,YAAY,uBAAuB,CAAC,CAAA;IAC1E,CAAC;IAED,SAAS,IAAI;QACX,SAAS,EAAE,OAAO,EAAE,CAAA;QACpB,SAAS,EAAE,OAAO,EAAE,CAAA;QACpB,SAAS,GAAG,IAAI,CAAA;QAChB,SAAS,GAAG,IAAI,CAAA;IAClB,CAAC;IAED,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,CAAA;AACxB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@better-forge/notify",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@better-forge/notify",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Notification channels, delivery history, retries and alert hygiene.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/BElluu/Better-Forge.git",
|
|
9
|
+
"directory": "packages/notify"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/BElluu/Better-Forge/tree/main/packages/notify#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/BElluu/Better-Forge/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"src",
|
|
31
|
+
"!src/**/*.test.*"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.json",
|
|
35
|
+
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"nodemailer": "^10.0.13",
|
|
39
|
+
"node-cron": "^4.6.0",
|
|
40
|
+
"@better-forge/audit": "0.1.0",
|
|
41
|
+
"@better-forge/contracts": "0.1.0",
|
|
42
|
+
"@better-forge/db": "0.1.0",
|
|
43
|
+
"@better-forge/vault": "0.1.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"drizzle-orm": "^0.45.2",
|
|
47
|
+
"fastify": "^5.3.2"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"drizzle-orm": "^0.45.2",
|
|
51
|
+
"fastify": "^5.3.2",
|
|
52
|
+
"@types/node": "^26.6.2",
|
|
53
|
+
"@types/nodemailer": "^8.0.1",
|
|
54
|
+
"typescript": "^5.7.3"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=26.10.0"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { QuietHoursPolicy } from '@better-forge/contracts'
|
|
2
|
+
|
|
3
|
+
const MINUTE_MS = 60_000
|
|
4
|
+
const DAY_MINUTES = 24 * 60
|
|
5
|
+
const TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/
|
|
6
|
+
|
|
7
|
+
const formatters = new Map<string, Intl.DateTimeFormat>()
|
|
8
|
+
|
|
9
|
+
export function isValidTimezone(timezone: string): boolean {
|
|
10
|
+
try {
|
|
11
|
+
new Intl.DateTimeFormat('en-GB', { timeZone: timezone })
|
|
12
|
+
return true
|
|
13
|
+
} catch {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatter(timezone: string): Intl.DateTimeFormat {
|
|
19
|
+
let cached = formatters.get(timezone)
|
|
20
|
+
if (!cached) {
|
|
21
|
+
cached = new Intl.DateTimeFormat('en-GB', { timeZone: timezone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
|
|
22
|
+
formatters.set(timezone, cached)
|
|
23
|
+
}
|
|
24
|
+
return cached
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Minutes elapsed since local midnight in `timezone` at the given instant. */
|
|
28
|
+
export function localMinutes(at: number, timezone: string): number {
|
|
29
|
+
const parts = formatter(isValidTimezone(timezone) ? timezone : 'UTC').formatToParts(new Date(at))
|
|
30
|
+
const hour = Number(parts.find((part) => part.type === 'hour')?.value ?? '0')
|
|
31
|
+
const minute = Number(parts.find((part) => part.type === 'minute')?.value ?? '0')
|
|
32
|
+
return hour * 60 + minute
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function timeToMinutes(value: string): number {
|
|
36
|
+
const match = TIME_PATTERN.exec(value)
|
|
37
|
+
if (!match) return 0
|
|
38
|
+
return Number(match[1]) * 60 + Number(match[2])
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** True when `at` falls inside the channel's quiet window. Windows where end <= start wrap midnight. */
|
|
42
|
+
export function isWithinQuietHours(policy: QuietHoursPolicy, at: number): boolean {
|
|
43
|
+
if (!policy.enabled) return false
|
|
44
|
+
const start = timeToMinutes(policy.start)
|
|
45
|
+
const end = timeToMinutes(policy.end)
|
|
46
|
+
if (start === end) return false // zero-length window — nothing is ever quiet
|
|
47
|
+
const now = localMinutes(at, policy.timezone)
|
|
48
|
+
return start < end ? now >= start && now < end : now >= start || now < end
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The instant the current quiet window ends. Only meaningful while `isWithinQuietHours` holds. */
|
|
52
|
+
export function quietHoursEndAt(policy: QuietHoursPolicy, at: number): number {
|
|
53
|
+
const end = timeToMinutes(policy.end)
|
|
54
|
+
const now = localMinutes(at, policy.timezone)
|
|
55
|
+
const minutesAhead = (end - now + DAY_MINUTES) % DAY_MINUTES || DAY_MINUTES
|
|
56
|
+
let candidate = at - (at % MINUTE_MS) + minutesAhead * MINUTE_MS
|
|
57
|
+
// A DST shift inside the window moves the wall clock under us; nudge forward until it is over.
|
|
58
|
+
for (let guard = 0; guard < 8 && isWithinQuietHours(policy, candidate); guard++) candidate += 30 * MINUTE_MS
|
|
59
|
+
return candidate
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
63
|
+
return value && typeof value === 'object' ? value as Record<string, unknown> : {}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function asBool(value: unknown, fallback: boolean): boolean {
|
|
67
|
+
if (typeof value === 'boolean') return value
|
|
68
|
+
if (value === 1 || value === 0) return value === 1
|
|
69
|
+
return fallback
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function asInt(value: unknown, min: number, max: number, fallback: number): number {
|
|
73
|
+
const parsed = typeof value === 'number' ? value : Number(value)
|
|
74
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
75
|
+
return Math.min(max, Math.max(min, Math.round(parsed)))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function asTime(value: unknown, fallback: string): string {
|
|
79
|
+
return typeof value === 'string' && TIME_PATTERN.test(value) ? value : fallback
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ThrottlePolicy { enabled: boolean; maxAlerts: number; windowMinutes: number }
|
|
83
|
+
export interface GroupingPolicy { enabled: boolean; minSources: number; windowSeconds: number }
|
|
84
|
+
export interface AlertPolicy { quietHours: QuietHoursPolicy; throttle: ThrottlePolicy; grouping: GroupingPolicy }
|
|
85
|
+
|
|
86
|
+
export const DEFAULT_ALERT_POLICY: AlertPolicy = {
|
|
87
|
+
quietHours: { enabled: false, start: '22:00', end: '07:00', timezone: 'UTC', mode: 'defer' },
|
|
88
|
+
throttle: { enabled: false, maxAlerts: 3, windowMinutes: 60 },
|
|
89
|
+
grouping: { enabled: false, minSources: 3, windowSeconds: 60 },
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Alert policy parsing for an application that stores and sends the "at least this many sources" setting of the
|
|
94
|
+
* grouping under `minSourcesKey`. The package works on `minSources`; `toWire` gives the stored form back, with the
|
|
95
|
+
* keys in the order they have always been written in.
|
|
96
|
+
*/
|
|
97
|
+
export function createAlertPolicy(options: { minSourcesKey: string }) {
|
|
98
|
+
/** Coerces untrusted input (API body or stored JSON) into a complete, in-range policy. */
|
|
99
|
+
function normalizeAlertPolicy(input: unknown): AlertPolicy {
|
|
100
|
+
const raw = asRecord(input)
|
|
101
|
+
const quiet = asRecord(raw['quietHours'])
|
|
102
|
+
const throttle = asRecord(raw['throttle'])
|
|
103
|
+
const grouping = asRecord(raw['grouping'])
|
|
104
|
+
const timezone = typeof quiet['timezone'] === 'string' && isValidTimezone(quiet['timezone'])
|
|
105
|
+
? quiet['timezone']
|
|
106
|
+
: DEFAULT_ALERT_POLICY.quietHours.timezone
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
quietHours: {
|
|
110
|
+
enabled: asBool(quiet['enabled'], DEFAULT_ALERT_POLICY.quietHours.enabled),
|
|
111
|
+
start: asTime(quiet['start'], DEFAULT_ALERT_POLICY.quietHours.start),
|
|
112
|
+
end: asTime(quiet['end'], DEFAULT_ALERT_POLICY.quietHours.end),
|
|
113
|
+
timezone,
|
|
114
|
+
mode: quiet['mode'] === 'suppress' ? 'suppress' : 'defer',
|
|
115
|
+
},
|
|
116
|
+
throttle: {
|
|
117
|
+
enabled: asBool(throttle['enabled'], DEFAULT_ALERT_POLICY.throttle.enabled),
|
|
118
|
+
maxAlerts: asInt(throttle['maxAlerts'], 1, 100, DEFAULT_ALERT_POLICY.throttle.maxAlerts),
|
|
119
|
+
windowMinutes: asInt(throttle['windowMinutes'], 1, 1440, DEFAULT_ALERT_POLICY.throttle.windowMinutes),
|
|
120
|
+
},
|
|
121
|
+
grouping: {
|
|
122
|
+
enabled: asBool(grouping['enabled'], DEFAULT_ALERT_POLICY.grouping.enabled),
|
|
123
|
+
minSources: asInt(grouping[options.minSourcesKey], 2, 100, DEFAULT_ALERT_POLICY.grouping.minSources),
|
|
124
|
+
windowSeconds: asInt(grouping['windowSeconds'], 10, 900, DEFAULT_ALERT_POLICY.grouping.windowSeconds),
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Reads a channel's stored `alert_policy` column, falling back to defaults on anything unusable. */
|
|
130
|
+
function parseAlertPolicy(stored: string | null | undefined): AlertPolicy {
|
|
131
|
+
if (!stored) return normalizeAlertPolicy({})
|
|
132
|
+
try {
|
|
133
|
+
return normalizeAlertPolicy(JSON.parse(stored))
|
|
134
|
+
} catch {
|
|
135
|
+
return normalizeAlertPolicy({})
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The form that is stored and sent: `minSources` under the application's key, the other keys in their usual order. */
|
|
140
|
+
function toWire(policy: AlertPolicy): { quietHours: QuietHoursPolicy; throttle: ThrottlePolicy; grouping: Record<string, unknown> } {
|
|
141
|
+
return {
|
|
142
|
+
quietHours: policy.quietHours,
|
|
143
|
+
throttle: policy.throttle,
|
|
144
|
+
grouping: { enabled: policy.grouping.enabled, [options.minSourcesKey]: policy.grouping.minSources, windowSeconds: policy.grouping.windowSeconds },
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { normalizeAlertPolicy, parseAlertPolicy, toWire }
|
|
149
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { NotificationChannelType } from '@better-forge/contracts'
|
|
2
|
+
import { placeholderInAuthority } from './template.js'
|
|
3
|
+
|
|
4
|
+
export const CHANNEL_TYPES: readonly NotificationChannelType[] = ['email', 'webhook', 'discord', 'teams', 'slack', 'telegram', 'ntfy', 'pushover']
|
|
5
|
+
|
|
6
|
+
type TokenConfig = { vault?: { vaultId?: unknown; secretId?: unknown }; [key: string]: unknown }
|
|
7
|
+
|
|
8
|
+
function asConfig(config: unknown): TokenConfig {
|
|
9
|
+
return (config && typeof config === 'object' ? config : {}) as TokenConfig
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const isText = (value: unknown): value is string => typeof value === 'string' && value.trim() !== ''
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A credential is a vault reference or a direct token; an edited mask is not a token. A missing one is reported
|
|
16
|
+
* as `missing`, or accepted when `missing` is null (a public ntfy topic needs none).
|
|
17
|
+
*/
|
|
18
|
+
function credentialError(c: TokenConfig, key: string, label: string, missing: string | null): string | null {
|
|
19
|
+
if (c.vault) return Number.isInteger(c.vault.vaultId) && Number.isInteger(c.vault.secretId) && Number(c.vault.secretId) > 0 ? null : `Pick a vault secret for the ${label}`
|
|
20
|
+
const token = c[key]
|
|
21
|
+
if (token === undefined || token === null || token === '') return missing
|
|
22
|
+
if (typeof token !== 'string') return `The ${label} must be text`
|
|
23
|
+
if (!token.trim()) return missing
|
|
24
|
+
return token.includes('•') ? `Paste the full ${label} to replace the stored one` : null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Telegram needs a chat and a token, direct or from the vault; an edited mask is not a token. */
|
|
28
|
+
export function telegramConfigError(config: unknown): string | null {
|
|
29
|
+
const c = asConfig(config)
|
|
30
|
+
if (!isText(c['chatId'])) return 'Telegram needs a Chat ID'
|
|
31
|
+
return credentialError(c, 'botToken', 'bot token', 'Telegram needs a Bot Token')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const NTFY_TOPIC = /^[A-Za-z0-9_-]{1,64}$/
|
|
35
|
+
|
|
36
|
+
/** ntfy needs a topic; the server defaults to ntfy.sh and the access token is optional. */
|
|
37
|
+
export function ntfyConfigError(config: unknown): string | null {
|
|
38
|
+
const c = asConfig(config)
|
|
39
|
+
if (!isText(c['topic']) || !NTFY_TOPIC.test(c['topic'].trim())) return 'ntfy needs a topic of up to 64 letters, digits, "-" or "_"'
|
|
40
|
+
const server = c['serverUrl']
|
|
41
|
+
if (server !== undefined && server !== null && server !== '') {
|
|
42
|
+
if (typeof server !== 'string') return 'Server URL must be text'
|
|
43
|
+
let url: URL
|
|
44
|
+
try {
|
|
45
|
+
url = new URL(server)
|
|
46
|
+
} catch {
|
|
47
|
+
return 'Server URL is not a valid URL'
|
|
48
|
+
}
|
|
49
|
+
if (!/^https?:$/.test(url.protocol)) return 'Server URL must start with http:// or https://'
|
|
50
|
+
// The URL is not a masked field, so it must not carry a login, and a query would end up in the middle of the publish URL.
|
|
51
|
+
if (url.username || url.password || url.search || url.hash) return 'Server URL must not contain a login, query or fragment'
|
|
52
|
+
}
|
|
53
|
+
const problem = credentialError(c, 'accessToken', 'access token', null)
|
|
54
|
+
if (problem) return problem
|
|
55
|
+
// A token goes into a header; a character that cannot be sent there makes every delivery fail.
|
|
56
|
+
return typeof c['accessToken'] === 'string' && c['accessToken'] && !c.vault && !/^[\x21-\x7e•]+$/.test(c['accessToken']) ? 'The access token contains characters that cannot be sent in a header' : null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Pushover needs a user or group key and an application token, direct or from the vault. */
|
|
60
|
+
export function pushoverConfigError(config: unknown): string | null {
|
|
61
|
+
const c = asConfig(config)
|
|
62
|
+
if (!isText(c['userKey'])) return 'Pushover needs a User Key'
|
|
63
|
+
return credentialError(c, 'appToken', 'application token', 'Pushover needs an Application Token')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The first problem with a channel's config that depends on its type, or null. */
|
|
67
|
+
export function channelConfigError(type: string, config: unknown): string | null {
|
|
68
|
+
const overridesProblem = eventOverridesError(type, config)
|
|
69
|
+
if (overridesProblem) return overridesProblem
|
|
70
|
+
if (type === 'telegram') return telegramConfigError(config)
|
|
71
|
+
if (type === 'ntfy') return ntfyConfigError(config)
|
|
72
|
+
if (type === 'pushover') return pushoverConfigError(config)
|
|
73
|
+
if (type === 'webhook') {
|
|
74
|
+
const url = asConfig(config)['url']
|
|
75
|
+
if (typeof url === 'string' && placeholderInAuthority(url)) return 'A placeholder cannot be part of the host of the URL'
|
|
76
|
+
}
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The fields of a channel that word a message, per type. A channel may carry its own wording for one kind of event
|
|
82
|
+
* (`eventOverrides`, keyed by the event type) made of these fields only; the destination and the credentials always
|
|
83
|
+
* come from the channel itself, so an override can never send a message somewhere else.
|
|
84
|
+
*/
|
|
85
|
+
export const TEMPLATE_KEYS: Record<NotificationChannelType, readonly string[]> = {
|
|
86
|
+
email: ['subject', 'body'],
|
|
87
|
+
webhook: ['body'],
|
|
88
|
+
discord: ['content'],
|
|
89
|
+
teams: ['summary'],
|
|
90
|
+
slack: ['text'],
|
|
91
|
+
telegram: ['text'],
|
|
92
|
+
ntfy: ['text'],
|
|
93
|
+
pushover: ['text'],
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const MAX_OVERRIDE_TEXT = 10_000
|
|
97
|
+
const MAX_OVERRIDE_EVENTS = 8
|
|
98
|
+
const EVENT_KEY = /^[a-z_]{1,32}$/
|
|
99
|
+
|
|
100
|
+
/** What is wrong with the `eventOverrides` of a channel's config, or null. */
|
|
101
|
+
export function eventOverridesError(type: string, config: unknown): string | null {
|
|
102
|
+
const overrides = asConfig(config)['eventOverrides']
|
|
103
|
+
if (overrides === undefined) return null
|
|
104
|
+
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return 'eventOverrides must be an object'
|
|
105
|
+
const events = Object.entries(overrides)
|
|
106
|
+
if (events.length > MAX_OVERRIDE_EVENTS) return `eventOverrides can hold at most ${MAX_OVERRIDE_EVENTS} events`
|
|
107
|
+
const allowed = TEMPLATE_KEYS[type as NotificationChannelType] ?? []
|
|
108
|
+
for (const [event, fields] of events) {
|
|
109
|
+
if (!EVENT_KEY.test(event) || event in Object.prototype || event === 'prototype') return `eventOverrides has an invalid event name "${event}"`
|
|
110
|
+
if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return `eventOverrides.${event} must be an object`
|
|
111
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
112
|
+
if (!allowed.includes(field)) return `eventOverrides.${event}.${field} is not a message field of a ${type} channel (${allowed.join(', ')})`
|
|
113
|
+
if (typeof value !== 'string' || value.length > MAX_OVERRIDE_TEXT) return `eventOverrides.${event}.${field} must be text of up to ${MAX_OVERRIDE_TEXT} characters`
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The wording a kind of event has when its channel has none of its own: by channel type, then message field. */
|
|
120
|
+
export type DefaultWording = Record<string, Partial<Record<string, Record<string, string>>>>
|
|
121
|
+
|
|
122
|
+
/** How a channel words each kind of event: its own message fields for `ownEvents`, its override or the default for any other. */
|
|
123
|
+
export interface EventWording {
|
|
124
|
+
ownEvents: readonly string[]
|
|
125
|
+
defaults?: DefaultWording
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The config a message of the given kind of event is sent with. The channel's own message fields word the kinds in
|
|
130
|
+
* `wording.ownEvents`. Any other kind is worded by what the channel has for it (`eventOverrides`), and by the default
|
|
131
|
+
* for the fields it has nothing for, never by the channel's own fields, which were written for something else. A field
|
|
132
|
+
* left without wording is sent in the channel's standard form. Without `wording`, every kind uses the channel's own
|
|
133
|
+
* fields and an override only replaces some of them.
|
|
134
|
+
*/
|
|
135
|
+
export function configForEvent(type: string, config: Record<string, unknown>, event: string | undefined, wording?: EventWording): Record<string, unknown> {
|
|
136
|
+
const keys = TEMPLATE_KEYS[type as NotificationChannelType] ?? []
|
|
137
|
+
const own = !wording || event === undefined || wording.ownEvents.includes(event)
|
|
138
|
+
const all = config['eventOverrides'] as Record<string, Record<string, unknown>> | undefined
|
|
139
|
+
const overrides = event && all && typeof all === 'object' && Object.hasOwn(all, event) ? all[event] : undefined
|
|
140
|
+
const fields = overrides && typeof overrides === 'object' ? overrides : {}
|
|
141
|
+
const defaults = own ? {} : wording.defaults?.[event!]?.[type] ?? {}
|
|
142
|
+
if (own && !overrides) return config
|
|
143
|
+
|
|
144
|
+
const merged = { ...config }
|
|
145
|
+
for (const key of keys) {
|
|
146
|
+
if (!own) delete merged[key]
|
|
147
|
+
const text = fields[key] !== undefined && fields[key] !== '' ? fields[key] : defaults[key]
|
|
148
|
+
if (typeof text === 'string' && text !== '') merged[key] = text
|
|
149
|
+
}
|
|
150
|
+
// A request without a body cannot carry one, and the form drops it too.
|
|
151
|
+
if (type === 'webhook' && /^(GET|HEAD)$/i.test(String(merged['method'] ?? 'POST'))) delete merged['body']
|
|
152
|
+
return merged
|
|
153
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { SecretField } from '@better-forge/vault'
|
|
2
|
+
|
|
3
|
+
/** Which parts of a channel's configuration are secrets, by channel type. */
|
|
4
|
+
const WEBHOOK_URL_SECRET: readonly SecretField[] = [{ path: ['webhookUrl'] }]
|
|
5
|
+
|
|
6
|
+
export const CHANNEL_SECRETS: Readonly<Record<string, readonly SecretField[]>> = {
|
|
7
|
+
webhook: [{ mapAt: ['headers'] }],
|
|
8
|
+
slack: WEBHOOK_URL_SECRET,
|
|
9
|
+
discord: WEBHOOK_URL_SECRET,
|
|
10
|
+
teams: WEBHOOK_URL_SECRET,
|
|
11
|
+
telegram: [{ path: ['botToken'], tail: true }],
|
|
12
|
+
ntfy: [{ path: ['accessToken'], tail: true }],
|
|
13
|
+
pushover: [{ path: ['appToken'], tail: true }],
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Where a channel's secret ends up. Keeping a stored secret while this changes would let whoever edits the channel
|
|
18
|
+
* send it to a host of their choosing, so the secret must be entered again.
|
|
19
|
+
*/
|
|
20
|
+
export const CHANNEL_DESTINATIONS: readonly (readonly string[])[] = [['url'], ['serverUrl']]
|