@mantiq/notify 0.5.21 → 0.5.23
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/package.json +1 -1
- package/src/NotificationManager.ts +97 -2
- package/src/channels/WebhookChannel.ts +82 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -4,6 +4,7 @@ import type { NotifyConfig } from './contracts/NotifyConfig.ts'
|
|
|
4
4
|
import { DEFAULT_CONFIG } from './contracts/NotifyConfig.ts'
|
|
5
5
|
import { Notification } from './Notification.ts'
|
|
6
6
|
import { NotifyError } from './errors/NotifyError.ts'
|
|
7
|
+
import { NotificationSent, NotificationFailed } from './events/NotificationEvents.ts'
|
|
7
8
|
import { MailChannel } from './channels/MailChannel.ts'
|
|
8
9
|
import { DatabaseChannel } from './channels/DatabaseChannel.ts'
|
|
9
10
|
import { BroadcastChannel } from './channels/BroadcastChannel.ts'
|
|
@@ -27,10 +28,22 @@ import { FirebaseChannel } from './channels/FirebaseChannel.ts'
|
|
|
27
28
|
* await notify().send(user, new OrderShipped(order))
|
|
28
29
|
* await notify().send([user1, user2], new OrderShipped(order))
|
|
29
30
|
*/
|
|
31
|
+
export interface DeliveryLogEntry {
|
|
32
|
+
id: string
|
|
33
|
+
notification: string
|
|
34
|
+
channel: string
|
|
35
|
+
recipient: string
|
|
36
|
+
status: 'sent' | 'failed'
|
|
37
|
+
sentAt: Date
|
|
38
|
+
error?: string | undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
30
41
|
export class NotificationManager {
|
|
31
42
|
private _channels = new Map<string, NotificationChannel>()
|
|
32
43
|
private _factories = new Map<string, () => NotificationChannel>()
|
|
33
44
|
private config: NotifyConfig
|
|
45
|
+
private _deliveryLog: DeliveryLogEntry[] = []
|
|
46
|
+
private _eventObservers: ((event: NotificationSent | NotificationFailed) => void)[] = []
|
|
34
47
|
|
|
35
48
|
constructor(config?: Partial<NotifyConfig>) {
|
|
36
49
|
this.config = { ...DEFAULT_CONFIG, ...config }
|
|
@@ -53,14 +66,14 @@ export class NotificationManager {
|
|
|
53
66
|
}
|
|
54
67
|
}
|
|
55
68
|
|
|
56
|
-
/** Send immediately, bypassing queue */
|
|
69
|
+
/** Send immediately, bypassing queue. Uses retry for each channel. */
|
|
57
70
|
async sendNow(notifiable: Notifiable, notification: Notification, channelNames?: string[]): Promise<void> {
|
|
58
71
|
const channels = channelNames ?? notification.via(notifiable)
|
|
59
72
|
|
|
60
73
|
for (const channelName of channels) {
|
|
61
74
|
try {
|
|
62
75
|
const channel = this.channel(channelName)
|
|
63
|
-
await
|
|
76
|
+
await this.sendWithRetry(notifiable, notification, channel, 3)
|
|
64
77
|
} catch (err) {
|
|
65
78
|
// Log but don't fail other channels
|
|
66
79
|
console.error(`[Mantiq] Notification channel "${channelName}" failed:`, (err as Error).message)
|
|
@@ -68,6 +81,78 @@ export class NotificationManager {
|
|
|
68
81
|
}
|
|
69
82
|
}
|
|
70
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Send a notification through a channel with retry logic.
|
|
86
|
+
* Records delivery status and emits events.
|
|
87
|
+
*/
|
|
88
|
+
async sendWithRetry(
|
|
89
|
+
notifiable: Notifiable,
|
|
90
|
+
notification: Notification,
|
|
91
|
+
channel: NotificationChannel,
|
|
92
|
+
maxRetries: number = 1,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
let lastError: Error | undefined
|
|
95
|
+
|
|
96
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
97
|
+
try {
|
|
98
|
+
await channel.send(notifiable, notification)
|
|
99
|
+
|
|
100
|
+
// Success — record and emit
|
|
101
|
+
this._deliveryLog.push({
|
|
102
|
+
id: notification.id,
|
|
103
|
+
notification: notification.constructor.name,
|
|
104
|
+
channel: channel.name,
|
|
105
|
+
recipient: String(notifiable.getKey()),
|
|
106
|
+
status: 'sent',
|
|
107
|
+
sentAt: new Date(),
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
this.emitEvent(new NotificationSent(notifiable, notification, channel.name))
|
|
111
|
+
return
|
|
112
|
+
} catch (err) {
|
|
113
|
+
lastError = err as Error
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// All retries exhausted — record failure and emit
|
|
118
|
+
this._deliveryLog.push({
|
|
119
|
+
id: notification.id,
|
|
120
|
+
notification: notification.constructor.name,
|
|
121
|
+
channel: channel.name,
|
|
122
|
+
recipient: String(notifiable.getKey()),
|
|
123
|
+
status: 'failed',
|
|
124
|
+
sentAt: new Date(),
|
|
125
|
+
error: lastError?.message,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
this.emitEvent(new NotificationFailed(notifiable, notification, channel.name, lastError!))
|
|
129
|
+
throw lastError!
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Delivery Log ──────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/** Get all delivery log entries. */
|
|
135
|
+
deliveryLog(): DeliveryLogEntry[] {
|
|
136
|
+
return [...this._deliveryLog]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Get deliveries for a specific notification ID. */
|
|
140
|
+
deliveriesFor(notificationId: string): DeliveryLogEntry[] {
|
|
141
|
+
return this._deliveryLog.filter((e) => e.id === notificationId)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Clear the delivery log. */
|
|
145
|
+
clearDeliveryLog(): void {
|
|
146
|
+
this._deliveryLog = []
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Delivery Events ───────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/** Register an observer for delivery events. */
|
|
152
|
+
onDeliveryEvent(observer: (event: NotificationSent | NotificationFailed) => void): void {
|
|
153
|
+
this._eventObservers.push(observer)
|
|
154
|
+
}
|
|
155
|
+
|
|
71
156
|
/** Get a channel by name */
|
|
72
157
|
channel(name: string): NotificationChannel {
|
|
73
158
|
if (this._channels.has(name)) return this._channels.get(name)!
|
|
@@ -125,6 +210,16 @@ export class NotificationManager {
|
|
|
125
210
|
if (ch?.firebase) this._factories.set('firebase', () => new FirebaseChannel(ch.firebase!))
|
|
126
211
|
}
|
|
127
212
|
|
|
213
|
+
private emitEvent(event: NotificationSent | NotificationFailed): void {
|
|
214
|
+
for (const observer of this._eventObservers) {
|
|
215
|
+
try {
|
|
216
|
+
observer(event)
|
|
217
|
+
} catch {
|
|
218
|
+
// Observer errors must not break delivery
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
128
223
|
private async queueNotification(notifiable: Notifiable, notification: Notification): Promise<void> {
|
|
129
224
|
try {
|
|
130
225
|
const { dispatch } = await import('@mantiq/queue')
|
|
@@ -16,6 +16,85 @@ import { NotifyError } from '../errors/NotifyError.ts'
|
|
|
16
16
|
export class WebhookChannel implements NotificationChannel {
|
|
17
17
|
readonly name = 'webhook'
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Security: validate webhook URL to prevent SSRF attacks.
|
|
21
|
+
* Rejects private/reserved IP ranges, localhost, link-local, and non-http(s) schemes
|
|
22
|
+
* so that attackers cannot target internal services (e.g. cloud metadata at 169.254.169.254).
|
|
23
|
+
*/
|
|
24
|
+
private validateUrl(url: string): void {
|
|
25
|
+
let parsed: URL
|
|
26
|
+
try {
|
|
27
|
+
parsed = new URL(url)
|
|
28
|
+
} catch {
|
|
29
|
+
throw new NotifyError('Webhook URL is not a valid URL', {
|
|
30
|
+
channel: this.name,
|
|
31
|
+
url,
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Only allow http and https
|
|
36
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
37
|
+
throw new NotifyError(`Webhook URL scheme "${parsed.protocol}" is not allowed — only http and https`, {
|
|
38
|
+
channel: this.name,
|
|
39
|
+
url,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const hostname = parsed.hostname
|
|
44
|
+
|
|
45
|
+
// Reject IPv6 addresses in brackets
|
|
46
|
+
const ipv6Match = hostname.match(/^\[(.+)\]$/)
|
|
47
|
+
if (ipv6Match) {
|
|
48
|
+
const ipv6 = ipv6Match[1]!.toLowerCase()
|
|
49
|
+
// Reject loopback (::1), link-local (fe80::), unique-local (fc00::/7 = fc and fd)
|
|
50
|
+
if (
|
|
51
|
+
ipv6 === '::1' ||
|
|
52
|
+
ipv6.startsWith('fe80:') ||
|
|
53
|
+
ipv6.startsWith('fc') ||
|
|
54
|
+
ipv6.startsWith('fd')
|
|
55
|
+
) {
|
|
56
|
+
throw new NotifyError('Webhook URL targets a private/reserved network address', {
|
|
57
|
+
channel: this.name,
|
|
58
|
+
url,
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Reject known private/reserved IPv4 ranges and localhost
|
|
64
|
+
const lower = hostname.toLowerCase()
|
|
65
|
+
if (
|
|
66
|
+
lower === 'localhost' ||
|
|
67
|
+
lower.endsWith('.localhost') ||
|
|
68
|
+
lower === '[::1]'
|
|
69
|
+
) {
|
|
70
|
+
throw new NotifyError('Webhook URL targets a private/reserved network address', {
|
|
71
|
+
channel: this.name,
|
|
72
|
+
url,
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Check IPv4-like hostnames
|
|
77
|
+
const ipv4Parts = hostname.split('.')
|
|
78
|
+
if (ipv4Parts.length === 4 && ipv4Parts.every((p) => /^\d{1,3}$/.test(p))) {
|
|
79
|
+
const octets = ipv4Parts.map(Number)
|
|
80
|
+
const [a, b] = octets as [number, number, number, number]
|
|
81
|
+
|
|
82
|
+
if (
|
|
83
|
+
a === 127 || // 127.0.0.0/8 — loopback
|
|
84
|
+
a === 10 || // 10.0.0.0/8 — private
|
|
85
|
+
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 — private
|
|
86
|
+
(a === 192 && b === 168) || // 192.168.0.0/16 — private
|
|
87
|
+
(a === 169 && b === 254) || // 169.254.0.0/16 — link-local (AWS metadata)
|
|
88
|
+
a === 0 // 0.0.0.0/8
|
|
89
|
+
) {
|
|
90
|
+
throw new NotifyError('Webhook URL targets a private/reserved network address', {
|
|
91
|
+
channel: this.name,
|
|
92
|
+
url,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
19
98
|
async send(notifiable: Notifiable, notification: Notification): Promise<void> {
|
|
20
99
|
const payload = notification.getPayloadFor('webhook', notifiable) as WebhookPayload | undefined
|
|
21
100
|
if (!payload) return
|
|
@@ -27,6 +106,9 @@ export class WebhookChannel implements NotificationChannel {
|
|
|
27
106
|
})
|
|
28
107
|
}
|
|
29
108
|
|
|
109
|
+
// Security: validate URL before making the request to prevent SSRF
|
|
110
|
+
this.validateUrl(payload.url)
|
|
111
|
+
|
|
30
112
|
const method = payload.method ?? 'POST'
|
|
31
113
|
const headers: Record<string, string> = {
|
|
32
114
|
'Content-Type': 'application/json',
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export type { NotifyConfig, SmsConfig, SlackConfig, TelegramConfig, WhatsAppConf
|
|
|
7
7
|
export { Notification } from './Notification.ts'
|
|
8
8
|
export type { SlackMessage, BroadcastPayload, SmsPayload, WebhookPayload } from './Notification.ts'
|
|
9
9
|
export { NotificationManager } from './NotificationManager.ts'
|
|
10
|
+
export type { DeliveryLogEntry } from './NotificationManager.ts'
|
|
10
11
|
|
|
11
12
|
// ── Channels ─────────────────────────────────────────────────────────────────
|
|
12
13
|
export { MailChannel } from './channels/MailChannel.ts'
|