@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.
@@ -1,153 +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
- }
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
+ }
@@ -1,20 +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']]
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']]