@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/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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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']]