@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.
Files changed (43) hide show
  1. package/README.md +36 -0
  2. package/package.json +48 -0
  3. package/src/drivers/broadcast/broadcast_config.ts +21 -0
  4. package/src/drivers/broadcast/broadcast_notification_driver.ts +105 -0
  5. package/src/drivers/broadcast/broadcast_notification_provider.ts +18 -0
  6. package/src/drivers/broadcast/index.ts +7 -0
  7. package/src/drivers/database/apply_notification_migration.ts +44 -0
  8. package/src/drivers/database/database_config.ts +7 -0
  9. package/src/drivers/database/database_notification_driver.ts +72 -0
  10. package/src/drivers/database/database_notification_provider.ts +44 -0
  11. package/src/drivers/database/index.ts +16 -0
  12. package/src/drivers/database/notification_record.ts +22 -0
  13. package/src/drivers/database/notification_repository.ts +67 -0
  14. package/src/drivers/database/schemas/notification_schema.ts +44 -0
  15. package/src/drivers/database/tenanted/apply_tenanted_notification_migration.ts +19 -0
  16. package/src/drivers/database/tenanted/index.ts +10 -0
  17. package/src/drivers/database/tenanted/schemas/tenanted_notification_schema.ts +28 -0
  18. package/src/drivers/database/tenanted/tenanted_notification_record.ts +15 -0
  19. package/src/drivers/database/tenanted/tenanted_notification_repository.ts +63 -0
  20. package/src/drivers/log/index.ts +6 -0
  21. package/src/drivers/log/log_config.ts +12 -0
  22. package/src/drivers/log/log_notification_driver.ts +72 -0
  23. package/src/drivers/log/log_notification_provider.ts +29 -0
  24. package/src/drivers/mail/index.ts +6 -0
  25. package/src/drivers/mail/mail_config.ts +7 -0
  26. package/src/drivers/mail/mail_notification_driver.ts +66 -0
  27. package/src/drivers/mail/mail_notification_provider.ts +18 -0
  28. package/src/drivers/mock.ts +48 -0
  29. package/src/drivers/unsupported.ts +15 -0
  30. package/src/drivers/webhook/index.ts +10 -0
  31. package/src/drivers/webhook/sign.ts +47 -0
  32. package/src/drivers/webhook/webhook_config.ts +43 -0
  33. package/src/drivers/webhook/webhook_notification_driver.ts +172 -0
  34. package/src/drivers/webhook/webhook_notification_provider.ts +44 -0
  35. package/src/index.ts +38 -0
  36. package/src/notifiable.ts +22 -0
  37. package/src/notification.ts +26 -0
  38. package/src/notification_config.ts +26 -0
  39. package/src/notification_driver.ts +40 -0
  40. package/src/notification_error.ts +62 -0
  41. package/src/notification_manager.ts +135 -0
  42. package/src/notification_provider.ts +42 -0
  43. 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,6 @@
1
+ export type { LogChannelConfig } from './log_config.ts'
2
+ export {
3
+ LogNotificationDriver,
4
+ type LogNotificationDriverOptions,
5
+ } from './log_notification_driver.ts'
6
+ export { LogNotificationProvider } from './log_notification_provider.ts'
@@ -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,6 @@
1
+ export type { MailChannelConfig } from './mail_config.ts'
2
+ export {
3
+ MailNotificationDriver,
4
+ type MailNotificationDriverOptions,
5
+ } from './mail_notification_driver.ts'
6
+ export { MailNotificationProvider } from './mail_notification_provider.ts'
@@ -0,0 +1,7 @@
1
+ import type { ChannelConfig } from '../../notification_config.ts'
2
+
3
+ export interface MailChannelConfig extends ChannelConfig {
4
+ driver: 'mail'
5
+ /** Optional named transport — passed through to `MailManager.via(name)`. */
6
+ transport?: string
7
+ }
@@ -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
+ }