@strav/notification 1.0.0-alpha.25
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/README.md +36 -0
- package/package.json +48 -0
- package/src/drivers/broadcast/broadcast_config.ts +21 -0
- package/src/drivers/broadcast/broadcast_notification_driver.ts +105 -0
- package/src/drivers/broadcast/broadcast_notification_provider.ts +18 -0
- package/src/drivers/broadcast/index.ts +7 -0
- package/src/drivers/database/apply_notification_migration.ts +44 -0
- package/src/drivers/database/database_config.ts +7 -0
- package/src/drivers/database/database_notification_driver.ts +72 -0
- package/src/drivers/database/database_notification_provider.ts +44 -0
- package/src/drivers/database/index.ts +16 -0
- package/src/drivers/database/notification_record.ts +22 -0
- package/src/drivers/database/notification_repository.ts +67 -0
- package/src/drivers/database/schemas/notification_schema.ts +44 -0
- package/src/drivers/database/tenanted/apply_tenanted_notification_migration.ts +19 -0
- package/src/drivers/database/tenanted/index.ts +10 -0
- package/src/drivers/database/tenanted/schemas/tenanted_notification_schema.ts +28 -0
- package/src/drivers/database/tenanted/tenanted_notification_record.ts +15 -0
- package/src/drivers/database/tenanted/tenanted_notification_repository.ts +63 -0
- package/src/drivers/log/index.ts +6 -0
- package/src/drivers/log/log_config.ts +12 -0
- package/src/drivers/log/log_notification_driver.ts +72 -0
- package/src/drivers/log/log_notification_provider.ts +29 -0
- package/src/drivers/mail/index.ts +6 -0
- package/src/drivers/mail/mail_config.ts +7 -0
- package/src/drivers/mail/mail_notification_driver.ts +66 -0
- package/src/drivers/mail/mail_notification_provider.ts +18 -0
- package/src/drivers/mock.ts +48 -0
- package/src/drivers/unsupported.ts +15 -0
- package/src/drivers/webhook/index.ts +10 -0
- package/src/drivers/webhook/sign.ts +47 -0
- package/src/drivers/webhook/webhook_config.ts +43 -0
- package/src/drivers/webhook/webhook_notification_driver.ts +172 -0
- package/src/drivers/webhook/webhook_notification_provider.ts +44 -0
- package/src/index.ts +38 -0
- package/src/notifiable.ts +22 -0
- package/src/notification.ts +26 -0
- package/src/notification_config.ts +26 -0
- package/src/notification_driver.ts +40 -0
- package/src/notification_error.ts +62 -0
- package/src/notification_manager.ts +135 -0
- package/src/notification_provider.ts +42 -0
- package/src/types.ts +34 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `TenantedNotificationRepository` — same surface as the
|
|
3
|
+
* non-tenanted `NotificationRepository`, scoped to the tenanted
|
|
4
|
+
* schema. Callers MUST be inside a `TenantManager.withTenant(...)`
|
|
5
|
+
* scope; the INSERT relies on the session's `app.tenant_id` setting
|
|
6
|
+
* (RLS).
|
|
7
|
+
*
|
|
8
|
+
* The implementation deliberately mirrors the non-tenanted Repository
|
|
9
|
+
* line-for-line — minor duplication keeps both variants narrowly
|
|
10
|
+
* scoped and avoids runtime branching on a tenancy flag (matches the
|
|
11
|
+
* `@strav/social/tenanted` pattern).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { quoteIdent, Repository } from '@strav/database'
|
|
15
|
+
import type { Notifiable } from '../../../notifiable.ts'
|
|
16
|
+
import { tenantedNotificationSchema } from './schemas/tenanted_notification_schema.ts'
|
|
17
|
+
import { TenantedNotificationRecord } from './tenanted_notification_record.ts'
|
|
18
|
+
|
|
19
|
+
export interface RecordInput {
|
|
20
|
+
id: string
|
|
21
|
+
notifiable: Notifiable
|
|
22
|
+
type: string
|
|
23
|
+
data: Record<string, unknown>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class TenantedNotificationRepository extends Repository<TenantedNotificationRecord> {
|
|
27
|
+
static override readonly schema = tenantedNotificationSchema
|
|
28
|
+
static override readonly model = TenantedNotificationRecord
|
|
29
|
+
|
|
30
|
+
async record(input: RecordInput): Promise<TenantedNotificationRecord> {
|
|
31
|
+
const now = new Date()
|
|
32
|
+
return this.create({
|
|
33
|
+
id: input.id,
|
|
34
|
+
notifiable_id: String(input.notifiable.id),
|
|
35
|
+
notifiable_type: input.notifiable.notifiableType ?? 'Notifiable',
|
|
36
|
+
type: input.type,
|
|
37
|
+
data: input.data,
|
|
38
|
+
read_at: null,
|
|
39
|
+
created_at: now,
|
|
40
|
+
updated_at: now,
|
|
41
|
+
} as Partial<TenantedNotificationRecord>)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async unread(notifiable: Notifiable): Promise<TenantedNotificationRecord[]> {
|
|
45
|
+
const table = quoteIdent(tenantedNotificationSchema.name)
|
|
46
|
+
const rows = await this.db.query<Record<string, unknown>>(
|
|
47
|
+
`SELECT * FROM ${table}
|
|
48
|
+
WHERE "notifiable_id" = $1 AND "read_at" IS NULL
|
|
49
|
+
ORDER BY "created_at" DESC`,
|
|
50
|
+
[String(notifiable.id)],
|
|
51
|
+
)
|
|
52
|
+
return rows.map((r) => this.hydrate(r))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async markAsRead(id: string): Promise<TenantedNotificationRecord | undefined> {
|
|
56
|
+
const found = await this.findMany([id])
|
|
57
|
+
const model = found[0]
|
|
58
|
+
if (!model) return undefined
|
|
59
|
+
return this.update(model, {
|
|
60
|
+
read_at: new Date(),
|
|
61
|
+
} as Partial<TenantedNotificationRecord>)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendor-specific config shape for the log channel. The discriminator
|
|
3
|
+
* `driver: 'log'` selects this factory at `manager.use(...)` time.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ChannelConfig } from '../../notification_config.ts'
|
|
7
|
+
|
|
8
|
+
export interface LogChannelConfig extends ChannelConfig {
|
|
9
|
+
driver: 'log'
|
|
10
|
+
/** Log level for this channel. Default `'info'`. */
|
|
11
|
+
level?: 'info' | 'warn' | 'error'
|
|
12
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `LogNotificationDriver` — writes notification records to the
|
|
3
|
+
* configured `Logger` channel. Useful for dev-mode + smoke tests
|
|
4
|
+
* where the cost of a real channel (mail, push, broadcast) isn't
|
|
5
|
+
* warranted.
|
|
6
|
+
*
|
|
7
|
+
* Reads `notification.toLog(notifiable)` to get the message body
|
|
8
|
+
* — apps' `BaseNotification` subclass implements that. When the
|
|
9
|
+
* hook is absent, the driver logs a minimal `{ type, id }` line
|
|
10
|
+
* so devs still see something fire without forcing every
|
|
11
|
+
* notification to define `toLog`.
|
|
12
|
+
*
|
|
13
|
+
* No external deps beyond `@strav/kernel`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Logger } from '@strav/kernel'
|
|
17
|
+
import type { Notifiable } from '../../notifiable.ts'
|
|
18
|
+
import type { BaseNotification } from '../../notification.ts'
|
|
19
|
+
import type { NotificationDriver } from '../../notification_driver.ts'
|
|
20
|
+
import type { NotificationContext, NotificationDeliveryResult } from '../../types.ts'
|
|
21
|
+
|
|
22
|
+
/** Optional hook surface — apps add `toLog(notifiable)` to their notification. */
|
|
23
|
+
interface LogCapableNotification extends BaseNotification {
|
|
24
|
+
toLog?(notifiable: Notifiable): string | Record<string, unknown>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LogNotificationDriverOptions {
|
|
28
|
+
/** Channel name surfaced as `driver.name`. Defaults to the manager-bound instance name. */
|
|
29
|
+
name: string
|
|
30
|
+
logger: Logger
|
|
31
|
+
/** Log level. Default `'info'`. */
|
|
32
|
+
level?: 'info' | 'warn' | 'error'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class LogNotificationDriver implements NotificationDriver {
|
|
36
|
+
readonly name: string
|
|
37
|
+
private readonly logger: Logger
|
|
38
|
+
private readonly level: 'info' | 'warn' | 'error'
|
|
39
|
+
|
|
40
|
+
constructor(options: LogNotificationDriverOptions) {
|
|
41
|
+
this.name = options.name
|
|
42
|
+
this.logger = options.logger
|
|
43
|
+
this.level = options.level ?? 'info'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async send(
|
|
47
|
+
notifiable: Notifiable,
|
|
48
|
+
notification: BaseNotification,
|
|
49
|
+
context: NotificationContext,
|
|
50
|
+
): Promise<NotificationDeliveryResult> {
|
|
51
|
+
const hook = (notification as LogCapableNotification).toLog
|
|
52
|
+
const payload = typeof hook === 'function' ? hook.call(notification, notifiable) : undefined
|
|
53
|
+
|
|
54
|
+
const fields: Record<string, unknown> = {
|
|
55
|
+
'notification.id': context.id,
|
|
56
|
+
'notification.type': notification.constructor.name,
|
|
57
|
+
'notification.channel': this.name,
|
|
58
|
+
'notifiable.id': notifiable.id,
|
|
59
|
+
}
|
|
60
|
+
if (typeof payload === 'string') {
|
|
61
|
+
this.logger[this.level](payload, fields)
|
|
62
|
+
} else if (payload !== undefined) {
|
|
63
|
+
this.logger[this.level](`${notification.constructor.name} dispatched to ${this.name}`, {
|
|
64
|
+
...fields,
|
|
65
|
+
...payload,
|
|
66
|
+
})
|
|
67
|
+
} else {
|
|
68
|
+
this.logger[this.level](`${notification.constructor.name} dispatched to ${this.name}`, fields)
|
|
69
|
+
}
|
|
70
|
+
return { channel: this.name, delivered: true, reference: context.id }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ServiceProvider that registers the log-channel factory on the
|
|
3
|
+
* `NotificationManager`. Apps include this in their provider list
|
|
4
|
+
* AFTER `NotificationProvider`; the factory then resolves whenever
|
|
5
|
+
* `config.notification.channels.<name>.driver === 'log'`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { type Application, Logger, ServiceProvider } from '@strav/kernel'
|
|
9
|
+
import { NotificationManager } from '../../notification_manager.ts'
|
|
10
|
+
import type { LogChannelConfig } from './log_config.ts'
|
|
11
|
+
import { LogNotificationDriver } from './log_notification_driver.ts'
|
|
12
|
+
|
|
13
|
+
export class LogNotificationProvider extends ServiceProvider {
|
|
14
|
+
override readonly name = 'notification.log'
|
|
15
|
+
override readonly dependencies = ['notification', 'logger']
|
|
16
|
+
|
|
17
|
+
override async boot(app: Application): Promise<void> {
|
|
18
|
+
const manager = app.resolve(NotificationManager)
|
|
19
|
+
const logger = app.resolve(Logger)
|
|
20
|
+
manager.extend('log', ({ instanceName, config }) => {
|
|
21
|
+
const cfg = config as LogChannelConfig
|
|
22
|
+
return new LogNotificationDriver({
|
|
23
|
+
name: instanceName,
|
|
24
|
+
logger,
|
|
25
|
+
...(cfg.level ? { level: cfg.level } : {}),
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MailNotificationDriver` — fans a notification into the configured
|
|
3
|
+
* `MailManager`. Reads `notification.toMail(notifiable)` for the
|
|
4
|
+
* message body; skips delivery (returns `{ delivered: false }` with
|
|
5
|
+
* no error) when the hook is absent — the channel chooses not to
|
|
6
|
+
* service notifications that don't model themselves as mail.
|
|
7
|
+
*
|
|
8
|
+
* Depends on `@strav/mail` (peer, optional on `@strav/notification`).
|
|
9
|
+
* Apps that want this driver register `MailNotificationProvider` AND
|
|
10
|
+
* have `MailProvider` + `MailManager` already in the container.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { MailManager, Message } from '@strav/mail'
|
|
14
|
+
import type { Notifiable } from '../../notifiable.ts'
|
|
15
|
+
import type { BaseNotification } from '../../notification.ts'
|
|
16
|
+
import type { NotificationDriver } from '../../notification_driver.ts'
|
|
17
|
+
import { NotificationDeliveryError } from '../../notification_error.ts'
|
|
18
|
+
import type { NotificationContext, NotificationDeliveryResult } from '../../types.ts'
|
|
19
|
+
|
|
20
|
+
/** Optional hook surface — apps add `toMail(notifiable)` on their notification. */
|
|
21
|
+
interface MailCapableNotification extends BaseNotification {
|
|
22
|
+
toMail?(notifiable: Notifiable): Message | Promise<Message>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MailNotificationDriverOptions {
|
|
26
|
+
name: string
|
|
27
|
+
mail: MailManager
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class MailNotificationDriver implements NotificationDriver {
|
|
31
|
+
readonly name: string
|
|
32
|
+
private readonly mail: MailManager
|
|
33
|
+
|
|
34
|
+
constructor(options: MailNotificationDriverOptions) {
|
|
35
|
+
this.name = options.name
|
|
36
|
+
this.mail = options.mail
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async send(
|
|
40
|
+
notifiable: Notifiable,
|
|
41
|
+
notification: BaseNotification,
|
|
42
|
+
context: NotificationContext,
|
|
43
|
+
): Promise<NotificationDeliveryResult> {
|
|
44
|
+
const hook = (notification as MailCapableNotification).toMail
|
|
45
|
+
if (typeof hook !== 'function') {
|
|
46
|
+
return { channel: this.name, delivered: false }
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const message = await hook.call(notification, notifiable)
|
|
50
|
+
await this.mail.send(message)
|
|
51
|
+
return { channel: this.name, delivered: true, reference: context.id }
|
|
52
|
+
} catch (cause) {
|
|
53
|
+
throw new NotificationDeliveryError(
|
|
54
|
+
`MailNotificationDriver: send failed for channel "${this.name}".`,
|
|
55
|
+
{
|
|
56
|
+
context: {
|
|
57
|
+
channel: this.name,
|
|
58
|
+
notifiableId: notifiable.id,
|
|
59
|
+
notification: notification.constructor.name,
|
|
60
|
+
},
|
|
61
|
+
cause,
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Application, ServiceProvider } from '@strav/kernel'
|
|
2
|
+
import { MailManager } from '@strav/mail'
|
|
3
|
+
import { NotificationManager } from '../../notification_manager.ts'
|
|
4
|
+
import { MailNotificationDriver } from './mail_notification_driver.ts'
|
|
5
|
+
|
|
6
|
+
export class MailNotificationProvider extends ServiceProvider {
|
|
7
|
+
override readonly name = 'notification.mail'
|
|
8
|
+
override readonly dependencies = ['notification', 'mail']
|
|
9
|
+
|
|
10
|
+
override async boot(app: Application): Promise<void> {
|
|
11
|
+
const notifications = app.resolve(NotificationManager)
|
|
12
|
+
const mail = app.resolve(MailManager)
|
|
13
|
+
notifications.extend(
|
|
14
|
+
'mail',
|
|
15
|
+
({ instanceName }) => new MailNotificationDriver({ name: instanceName, mail }),
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory notification recorder for tests. Captures every
|
|
3
|
+
* `(notifiable, notification, context)` triple and reports them
|
|
4
|
+
* as delivered. Apps under test assert on the recorded array.
|
|
5
|
+
*
|
|
6
|
+
* Pairs with `@strav/testing`-style flows: register via
|
|
7
|
+
* `manager.useDriver('test', new MockNotificationDriver())` after
|
|
8
|
+
* boot, send notifications, inspect `driver.records`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Notifiable } from '../notifiable.ts'
|
|
12
|
+
import type { BaseNotification } from '../notification.ts'
|
|
13
|
+
import type { NotificationDriver, NotificationDriverFactory } from '../notification_driver.ts'
|
|
14
|
+
import type { NotificationContext, NotificationDeliveryResult } from '../types.ts'
|
|
15
|
+
|
|
16
|
+
export interface MockNotificationRecord {
|
|
17
|
+
notifiable: Notifiable
|
|
18
|
+
notification: BaseNotification
|
|
19
|
+
context: NotificationContext
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class MockNotificationDriver implements NotificationDriver {
|
|
23
|
+
readonly records: MockNotificationRecord[] = []
|
|
24
|
+
|
|
25
|
+
constructor(public readonly name = 'mock') {}
|
|
26
|
+
|
|
27
|
+
async send(
|
|
28
|
+
notifiable: Notifiable,
|
|
29
|
+
notification: BaseNotification,
|
|
30
|
+
context: NotificationContext,
|
|
31
|
+
): Promise<NotificationDeliveryResult> {
|
|
32
|
+
this.records.push({ notifiable, notification, context })
|
|
33
|
+
return { channel: this.name, delivered: true, reference: context.id }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Drop everything recorded so far. Useful between assertions. */
|
|
37
|
+
clear(): void {
|
|
38
|
+
this.records.length = 0
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Factory shape so apps can register `driver: 'mock'` in config and
|
|
44
|
+
* get a fresh instance per channel. Defaults the channel name to the
|
|
45
|
+
* configured key (matches other channel factories).
|
|
46
|
+
*/
|
|
47
|
+
export const mockNotificationDriverFactory: NotificationDriverFactory = ({ instanceName }) =>
|
|
48
|
+
new MockNotificationDriver(instanceName)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared "throw unsupported" helper. Mirrors the same pattern in
|
|
3
|
+
* `@strav/payment/drivers/unsupported.ts` and
|
|
4
|
+
* `@strav/social/drivers/unsupported.ts` — kept inline for
|
|
5
|
+
* package-specific error code throwing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { NotificationDeliveryError } from '../notification_error.ts'
|
|
9
|
+
|
|
10
|
+
export function unsupported(channel: string, reason?: string): never {
|
|
11
|
+
throw new NotificationDeliveryError(
|
|
12
|
+
`Channel "${channel}" cannot perform this operation${reason ? `: ${reason}` : ''}.`,
|
|
13
|
+
{ context: { channel } },
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { signWebhook, verifyWebhookSignature } from './sign.ts'
|
|
2
|
+
export type {
|
|
3
|
+
WebhookChannelConfig,
|
|
4
|
+
WebhookSignatureAlgorithm,
|
|
5
|
+
} from './webhook_config.ts'
|
|
6
|
+
export {
|
|
7
|
+
WebhookNotificationDriver,
|
|
8
|
+
type WebhookNotificationDriverOptions,
|
|
9
|
+
} from './webhook_notification_driver.ts'
|
|
10
|
+
export { WebhookNotificationProvider } from './webhook_notification_provider.ts'
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HMAC signing helpers for the webhook channel.
|
|
3
|
+
*
|
|
4
|
+
* Wire shape — Stripe-style canonical signature:
|
|
5
|
+
*
|
|
6
|
+
* stringToSign = `${unixTimestampSeconds}.${rawJsonBody}`
|
|
7
|
+
* signature = HMAC-{algo}(stringToSign, secret) as hex
|
|
8
|
+
* header = `${algo}=${signature}`
|
|
9
|
+
*
|
|
10
|
+
* The leading timestamp in `stringToSign` is mandatory: it lets
|
|
11
|
+
* receivers reject replays by comparing the signed `x-strav-timestamp`
|
|
12
|
+
* against a tolerated window. Without it, a captured request body +
|
|
13
|
+
* signature pair stays valid forever.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
17
|
+
import type { WebhookSignatureAlgorithm } from './webhook_config.ts'
|
|
18
|
+
|
|
19
|
+
export function signWebhook(
|
|
20
|
+
algorithm: WebhookSignatureAlgorithm,
|
|
21
|
+
secret: string,
|
|
22
|
+
timestamp: string,
|
|
23
|
+
body: string,
|
|
24
|
+
): string {
|
|
25
|
+
return createHmac(algorithm, secret).update(`${timestamp}.${body}`).digest('hex')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Constant-time verification helper exported for receiver-side use.
|
|
30
|
+
* Apps consuming `@strav/notification` webhooks call this on incoming
|
|
31
|
+
* requests; rejects on length mismatch (short-circuit) and on byte
|
|
32
|
+
* mismatch (timing-safe compare).
|
|
33
|
+
*/
|
|
34
|
+
export function verifyWebhookSignature(
|
|
35
|
+
algorithm: WebhookSignatureAlgorithm,
|
|
36
|
+
secret: string,
|
|
37
|
+
timestamp: string,
|
|
38
|
+
body: string,
|
|
39
|
+
receivedSignatureHex: string,
|
|
40
|
+
): boolean {
|
|
41
|
+
const expected = signWebhook(algorithm, secret, timestamp, body)
|
|
42
|
+
if (expected.length !== receivedSignatureHex.length) return false
|
|
43
|
+
return timingSafeEqual(
|
|
44
|
+
new Uint8Array(Buffer.from(expected, 'utf-8')),
|
|
45
|
+
new Uint8Array(Buffer.from(receivedSignatureHex, 'utf-8')),
|
|
46
|
+
)
|
|
47
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendor-specific config shape for the webhook channel. The
|
|
3
|
+
* discriminator `driver: 'webhook'` selects this factory at
|
|
4
|
+
* `manager.use(...)` time.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ChannelConfig } from '../../notification_config.ts'
|
|
8
|
+
|
|
9
|
+
export type WebhookSignatureAlgorithm = 'sha256' | 'sha1' | 'sha512'
|
|
10
|
+
|
|
11
|
+
export interface WebhookChannelConfig extends ChannelConfig {
|
|
12
|
+
driver: 'webhook'
|
|
13
|
+
/**
|
|
14
|
+
* Endpoint URL to POST notifications to. Required at the config
|
|
15
|
+
* level — apps that need per-recipient routing register multiple
|
|
16
|
+
* webhook channels and pick between them in `notification.via()`.
|
|
17
|
+
*/
|
|
18
|
+
endpoint: string
|
|
19
|
+
/**
|
|
20
|
+
* HMAC secret used to sign every request. Required. Pull from env
|
|
21
|
+
* in `config/notification.ts`; never hard-code. Rotating the secret
|
|
22
|
+
* requires coordinating with every receiver.
|
|
23
|
+
*/
|
|
24
|
+
secret: string
|
|
25
|
+
/**
|
|
26
|
+
* Hash algorithm for the HMAC. Default `'sha256'`. Receivers should
|
|
27
|
+
* compute against the same algorithm — the algorithm name is also
|
|
28
|
+
* sent as the prefix on the `x-strav-signature` header so receivers
|
|
29
|
+
* can validate during rotations.
|
|
30
|
+
*/
|
|
31
|
+
algorithm?: WebhookSignatureAlgorithm
|
|
32
|
+
/**
|
|
33
|
+
* Headers added to every request. Merge order: built-in (`x-strav-*`,
|
|
34
|
+
* `content-type`) → these → per-request override is NOT supported (the
|
|
35
|
+
* notification only contributes the body, not the transport metadata).
|
|
36
|
+
* Useful for auth tokens the receiver requires in addition to the
|
|
37
|
+
* HMAC signature (a fixed `Authorization` header on the receiving
|
|
38
|
+
* service, an `x-tenant-id`, etc.).
|
|
39
|
+
*/
|
|
40
|
+
headers?: Record<string, string>
|
|
41
|
+
/** Request timeout in ms. Default `5000`. */
|
|
42
|
+
timeoutMs?: number
|
|
43
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `WebhookNotificationDriver` — POSTs notifications to a configured
|
|
3
|
+
* HTTPS endpoint, HMAC-signed.
|
|
4
|
+
*
|
|
5
|
+
* The wire shape:
|
|
6
|
+
*
|
|
7
|
+
* POST {endpoint}
|
|
8
|
+
* content-type: application/json
|
|
9
|
+
* x-strav-notification-id: 01J... ← ULID, matches NotificationContext.id
|
|
10
|
+
* x-strav-notification-type: InvoicePaid ← notification subclass name
|
|
11
|
+
* x-strav-timestamp: 1737000000 ← unix seconds at send time
|
|
12
|
+
* x-strav-signature: sha256=ab12... ← HMAC over `${timestamp}.${body}`
|
|
13
|
+
* [...configured headers...]
|
|
14
|
+
*
|
|
15
|
+
* {
|
|
16
|
+
* "notification": { "id": "01J...", "type": "InvoicePaid",
|
|
17
|
+
* "dispatchedAt": "2026-05-30T08:30:00.000Z" },
|
|
18
|
+
* "notifiable": { "id": "u_1", "type": "User" },
|
|
19
|
+
* "data": { ...whatever toWebhook returned... }
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* Verification on the receiver:
|
|
23
|
+
*
|
|
24
|
+
* import { verifyWebhookSignature } from '@strav/notification/webhook'
|
|
25
|
+
* const [algo, sig] = req.headers['x-strav-signature'].split('=')
|
|
26
|
+
* if (!verifyWebhookSignature(algo, SECRET, req.headers['x-strav-timestamp'],
|
|
27
|
+
* rawBody, sig)) return 401
|
|
28
|
+
* if (Math.abs(now - Number(req.headers['x-strav-timestamp'])) > 300) return 401
|
|
29
|
+
*
|
|
30
|
+
* Reads `notification.toWebhook(notifiable)` for the body data. Skips
|
|
31
|
+
* delivery (returns `{ delivered: false }` with no error) when the hook
|
|
32
|
+
* is absent — channel-level opt-out is intentional, same as the mail
|
|
33
|
+
* driver. Throws `NotificationDeliveryError` on non-2xx, network
|
|
34
|
+
* failure, or timeout — the manager captures it into the dispatch
|
|
35
|
+
* result without rethrowing.
|
|
36
|
+
*
|
|
37
|
+
* No external deps. Stays pure-fetch; the only Node-stdlib reach is
|
|
38
|
+
* `node:crypto` for HMAC.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import type { Notifiable } from '../../notifiable.ts'
|
|
42
|
+
import type { BaseNotification } from '../../notification.ts'
|
|
43
|
+
import type { NotificationDriver } from '../../notification_driver.ts'
|
|
44
|
+
import { NotificationDeliveryError } from '../../notification_error.ts'
|
|
45
|
+
import type { NotificationContext, NotificationDeliveryResult } from '../../types.ts'
|
|
46
|
+
import { signWebhook } from './sign.ts'
|
|
47
|
+
import type { WebhookSignatureAlgorithm } from './webhook_config.ts'
|
|
48
|
+
|
|
49
|
+
/** Optional hook surface — apps add `toWebhook(notifiable)` on their notification. */
|
|
50
|
+
interface WebhookCapableNotification extends BaseNotification {
|
|
51
|
+
toWebhook?(notifiable: Notifiable): unknown | Promise<unknown>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface WebhookNotificationDriverOptions {
|
|
55
|
+
name: string
|
|
56
|
+
endpoint: string
|
|
57
|
+
secret: string
|
|
58
|
+
algorithm?: WebhookSignatureAlgorithm
|
|
59
|
+
headers?: Record<string, string>
|
|
60
|
+
timeoutMs?: number
|
|
61
|
+
/** Custom `fetch` for tests. */
|
|
62
|
+
fetch?: typeof fetch
|
|
63
|
+
/** Override clock for deterministic signatures in tests. */
|
|
64
|
+
now?: () => Date
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class WebhookNotificationDriver implements NotificationDriver {
|
|
68
|
+
readonly name: string
|
|
69
|
+
private readonly endpoint: string
|
|
70
|
+
private readonly secret: string
|
|
71
|
+
private readonly algorithm: WebhookSignatureAlgorithm
|
|
72
|
+
private readonly extraHeaders: Record<string, string>
|
|
73
|
+
private readonly timeoutMs: number
|
|
74
|
+
private readonly fetchFn: typeof fetch
|
|
75
|
+
private readonly nowFn: () => Date
|
|
76
|
+
|
|
77
|
+
constructor(options: WebhookNotificationDriverOptions) {
|
|
78
|
+
this.name = options.name
|
|
79
|
+
this.endpoint = options.endpoint
|
|
80
|
+
this.secret = options.secret
|
|
81
|
+
this.algorithm = options.algorithm ?? 'sha256'
|
|
82
|
+
this.extraHeaders = options.headers ?? {}
|
|
83
|
+
this.timeoutMs = options.timeoutMs ?? 5000
|
|
84
|
+
this.fetchFn = options.fetch ?? fetch
|
|
85
|
+
this.nowFn = options.now ?? (() => new Date())
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async send(
|
|
89
|
+
notifiable: Notifiable,
|
|
90
|
+
notification: BaseNotification,
|
|
91
|
+
context: NotificationContext,
|
|
92
|
+
): Promise<NotificationDeliveryResult> {
|
|
93
|
+
const hook = (notification as WebhookCapableNotification).toWebhook
|
|
94
|
+
if (typeof hook !== 'function') {
|
|
95
|
+
return { channel: this.name, delivered: false }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const data = await hook.call(notification, notifiable)
|
|
99
|
+
const envelope = {
|
|
100
|
+
notification: {
|
|
101
|
+
id: context.id,
|
|
102
|
+
type: notification.constructor.name,
|
|
103
|
+
dispatchedAt: context.dispatchedAt.toISOString(),
|
|
104
|
+
},
|
|
105
|
+
notifiable: {
|
|
106
|
+
id: notifiable.id,
|
|
107
|
+
...(notifiable.notifiableType !== undefined ? { type: notifiable.notifiableType } : {}),
|
|
108
|
+
},
|
|
109
|
+
data,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const body = JSON.stringify(envelope)
|
|
113
|
+
const timestamp = Math.floor(this.nowFn().getTime() / 1000).toString()
|
|
114
|
+
const signature = signWebhook(this.algorithm, this.secret, timestamp, body)
|
|
115
|
+
|
|
116
|
+
const headers: Record<string, string> = {
|
|
117
|
+
...this.extraHeaders,
|
|
118
|
+
'content-type': 'application/json',
|
|
119
|
+
'x-strav-notification-id': context.id,
|
|
120
|
+
'x-strav-notification-type': notification.constructor.name,
|
|
121
|
+
'x-strav-timestamp': timestamp,
|
|
122
|
+
'x-strav-signature': `${this.algorithm}=${signature}`,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let response: Response
|
|
126
|
+
try {
|
|
127
|
+
response = await this.fetchFn(this.endpoint, {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers,
|
|
130
|
+
body,
|
|
131
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
132
|
+
})
|
|
133
|
+
} catch (cause) {
|
|
134
|
+
throw new NotificationDeliveryError(
|
|
135
|
+
`WebhookNotificationDriver: network failure for channel "${this.name}".`,
|
|
136
|
+
{
|
|
137
|
+
context: {
|
|
138
|
+
channel: this.name,
|
|
139
|
+
endpoint: this.endpoint,
|
|
140
|
+
notifiableId: notifiable.id,
|
|
141
|
+
notification: notification.constructor.name,
|
|
142
|
+
retryable: true,
|
|
143
|
+
},
|
|
144
|
+
cause,
|
|
145
|
+
},
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (response.ok) {
|
|
150
|
+
return { channel: this.name, delivered: true, reference: context.id }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Best-effort capture of the response body for diagnostics — truncated
|
|
154
|
+
// so a 50MB HTML error page from a misconfigured reverse proxy doesn't
|
|
155
|
+
// bloat log records.
|
|
156
|
+
const responseBody = await response.text().catch(() => '')
|
|
157
|
+
throw new NotificationDeliveryError(
|
|
158
|
+
`WebhookNotificationDriver: endpoint responded HTTP ${response.status} ${response.statusText}.`,
|
|
159
|
+
{
|
|
160
|
+
context: {
|
|
161
|
+
channel: this.name,
|
|
162
|
+
endpoint: this.endpoint,
|
|
163
|
+
notifiableId: notifiable.id,
|
|
164
|
+
notification: notification.constructor.name,
|
|
165
|
+
status: response.status,
|
|
166
|
+
retryable: response.status >= 500 || response.status === 408 || response.status === 429,
|
|
167
|
+
responseBody: responseBody.slice(0, 1024),
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ServiceProvider that registers the webhook-channel factory on the
|
|
3
|
+
* `NotificationManager`. Apps include this in their provider list
|
|
4
|
+
* AFTER `NotificationProvider`; the factory then resolves whenever
|
|
5
|
+
* `config.notification.channels.<name>.driver === 'webhook'`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { type Application, ServiceProvider } from '@strav/kernel'
|
|
9
|
+
import { NotificationConfigError } from '../../notification_error.ts'
|
|
10
|
+
import { NotificationManager } from '../../notification_manager.ts'
|
|
11
|
+
import type { WebhookChannelConfig } from './webhook_config.ts'
|
|
12
|
+
import { WebhookNotificationDriver } from './webhook_notification_driver.ts'
|
|
13
|
+
|
|
14
|
+
export class WebhookNotificationProvider extends ServiceProvider {
|
|
15
|
+
override readonly name = 'notification.webhook'
|
|
16
|
+
override readonly dependencies = ['notification']
|
|
17
|
+
|
|
18
|
+
override async boot(app: Application): Promise<void> {
|
|
19
|
+
const manager = app.resolve(NotificationManager)
|
|
20
|
+
manager.extend('webhook', ({ instanceName, config }) => {
|
|
21
|
+
const cfg = config as WebhookChannelConfig
|
|
22
|
+
if (!cfg.endpoint) {
|
|
23
|
+
throw new NotificationConfigError(
|
|
24
|
+
`WebhookNotificationProvider: channel "${instanceName}" requires a non-empty \`endpoint\`.`,
|
|
25
|
+
{ context: { channel: instanceName } },
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
if (!cfg.secret) {
|
|
29
|
+
throw new NotificationConfigError(
|
|
30
|
+
`WebhookNotificationProvider: channel "${instanceName}" requires a non-empty \`secret\`.`,
|
|
31
|
+
{ context: { channel: instanceName } },
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
return new WebhookNotificationDriver({
|
|
35
|
+
name: instanceName,
|
|
36
|
+
endpoint: cfg.endpoint,
|
|
37
|
+
secret: cfg.secret,
|
|
38
|
+
...(cfg.algorithm !== undefined ? { algorithm: cfg.algorithm } : {}),
|
|
39
|
+
...(cfg.headers !== undefined ? { headers: cfg.headers } : {}),
|
|
40
|
+
...(cfg.timeoutMs !== undefined ? { timeoutMs: cfg.timeoutMs } : {}),
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
}
|