@usehenri/webhooks 0.0.0 → 1.2.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.
@@ -0,0 +1,1219 @@
1
+ const { randomUUID } = require('crypto');
2
+ const debug = require('debug')('henri:webhooks');
3
+
4
+ const { WebhookError, coded } = require('./errors');
5
+ const { active, fresh, keyring, open, rotate, seal } = require('./secrets');
6
+ const { deliver } = require('./deliver');
7
+ const { parse: parseUrl } = require('./address');
8
+ const { headersFor } = require('./signature');
9
+ const { normalize } = require('./config');
10
+ const { storeFor } = require('./store');
11
+
12
+ /**
13
+ * Outbound webhooks: what an application sees as `henri.webhooks`.
14
+ *
15
+ * ## Where the endpoints live
16
+ *
17
+ * In a table this package owns, `henri_webhooks`, reached through the store
18
+ * adapter the way the queue reaches `henri_jobs`. The three candidates were
19
+ * the configuration, a model the application writes, and this; the first
20
+ * cannot hold a per-tenant secret or a rotation, and the second makes every
21
+ * application carry a migration, a validation and a secret column for a
22
+ * table it did not want to design -- and gets the storage of the secret
23
+ * wrong, which is the whole point of the feature.
24
+ *
25
+ * What that costs, said plainly:
26
+ *
27
+ * - **many tenants** work because an endpoint carries an `owner`, which is
28
+ * the tenant's id, and `emit(event, data, { owner })` only ever loads and
29
+ * sends to that tenant's endpoints. The index is on `(owner,
30
+ * disabled_at)`.
31
+ * - **many endpoints** work up to a point: a lookup loads the enabled
32
+ * endpoints of one owner (of the whole application when there is no
33
+ * owner) and matches the event pattern here, because no two of the four
34
+ * SQL dialects agree on how to ask that of a JSON column. The list is
35
+ * cached in `henri.cache` for ten seconds, without the secrets, so a busy
36
+ * event costs one query per owner per ten seconds and not one per event.
37
+ * - the ceiling is `webhooks.maxFanout` (a thousand by default): past it,
38
+ * `emit()` refuses rather than writing ten thousand rows inside a
39
+ * request. An application that really has that many endpoints for one
40
+ * event emits from a job.
41
+ * - an endpoint is a row, not a model: it has no validations of yours, no
42
+ * hooks and no `paranoid`. `henri.webhooks.register()` is the only way in.
43
+ *
44
+ * ## Where the deliveries live
45
+ *
46
+ * In the queue. A delivery is one `henri/webhook` job, so the retries, the
47
+ * backoff and the dead letter queue are the queue's and there is no second
48
+ * mechanism to learn, no second table to prune and no second answer to
49
+ * "what happened to it". `henri jobs:list --queue webhooks`,
50
+ * `henri jobs:dead` and `henri jobs:show <id>` are the operator's view.
51
+ */
52
+
53
+ /** The job that performs one delivery */
54
+ const DELIVERY_JOB = 'henri/webhook';
55
+
56
+ /** How long an endpoint lookup is cached */
57
+ const CACHE_TTL = 10000;
58
+
59
+ /** How many event patterns one endpoint may carry */
60
+ const MAX_EVENTS = 100;
61
+
62
+ /** How many headers of its own one endpoint may carry */
63
+ const MAX_HEADERS = 10;
64
+
65
+ /** How long an owner may be: the width of the column it is indexed in */
66
+ const MAX_OWNER = 190;
67
+
68
+ /** What an event name may look like */
69
+ const EVENT = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/u;
70
+
71
+ /** What a subscription may look like: an event name, `*`, or `prefix.*` */
72
+ const PATTERN = /^(?:\*|[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*(?:\.\*)?)$/u;
73
+
74
+ /**
75
+ * Headers an endpoint may not set: the ones the signature owns, the ones
76
+ * the request owns, and the hop-by-hop ones a proxy would strip anyway
77
+ */
78
+ const RESERVED = [
79
+ 'connection',
80
+ 'content-length',
81
+ 'content-type',
82
+ 'expect',
83
+ 'host',
84
+ 'keep-alive',
85
+ 'proxy-authorization',
86
+ 'te',
87
+ 'trailer',
88
+ 'transfer-encoding',
89
+ 'upgrade',
90
+ 'user-agent',
91
+ 'webhook-id',
92
+ 'webhook-signature',
93
+ 'webhook-timestamp',
94
+ ];
95
+
96
+ /**
97
+ * Reads a stored JSON column back
98
+ *
99
+ * @param {*} value What the row holds
100
+ * @param {*} [fallback=null] What to answer when there is nothing
101
+ * @returns {*} The value
102
+ */
103
+ const parse = (value, fallback = null) => {
104
+ if (value === null || typeof value === 'undefined') {
105
+ return fallback;
106
+ }
107
+
108
+ if (typeof value !== 'string') {
109
+ return value;
110
+ }
111
+
112
+ try {
113
+ return JSON.parse(value);
114
+ } catch (error) {
115
+ return fallback;
116
+ }
117
+ };
118
+
119
+ /**
120
+ * A number read back from any driver (pg hands BIGINT over as a string)
121
+ *
122
+ * @param {*} value The stored value
123
+ * @returns {?number} The number, or null
124
+ */
125
+ const toNumber = (value) => {
126
+ if (value === null || typeof value === 'undefined' || value === '') {
127
+ return null;
128
+ }
129
+
130
+ const number = Number(value);
131
+
132
+ return Number.isNaN(number) ? null : number;
133
+ };
134
+
135
+ /**
136
+ * A moment, as the API hands it out
137
+ *
138
+ * @param {*} value A timestamp in milliseconds
139
+ * @returns {?string} An ISO string, or null
140
+ */
141
+ const at = (value) => {
142
+ const number = toNumber(value);
143
+
144
+ return number === null ? null : new Date(number).toISOString();
145
+ };
146
+
147
+ /**
148
+ * Whether an endpoint subscribed to an event
149
+ *
150
+ * A subscription is the event name, `*` for everything, or `prefix.*` for a
151
+ * family (`invoice.*` takes `invoice.paid` and `invoice.payment.failed`).
152
+ * Nothing else: a glob language is a second thing to get wrong, and the
153
+ * fan-out policy beyond this is the application's.
154
+ *
155
+ * @param {Array<string>} patterns What the endpoint subscribed to
156
+ * @param {string} event The event name
157
+ * @returns {boolean} Whether it is subscribed
158
+ */
159
+ const subscribed = (patterns, event) =>
160
+ (patterns || []).some((pattern) => {
161
+ if (pattern === '*' || pattern === event) {
162
+ return true;
163
+ }
164
+
165
+ return pattern.endsWith('.*') && event.startsWith(pattern.slice(0, -1));
166
+ });
167
+
168
+ /**
169
+ * The endpoints of an application
170
+ *
171
+ * @class Webhooks
172
+ */
173
+ class Webhooks {
174
+ /**
175
+ * Creates an instance of Webhooks.
176
+ *
177
+ * @param {object} henri The henri instance
178
+ * @param {object} [options={}] Options
179
+ * @param {object} [options.config] The `webhooks` block
180
+ * @param {object} [options.adapter] The store adapter, when it is not
181
+ * taken from `henri.model`
182
+ * @param {string} [options.agent] The user agent deliveries carry
183
+ * @memberof Webhooks
184
+ */
185
+ constructor(henri, options = {}) {
186
+ this.henri = henri;
187
+ this.pen = (henri && henri.pen) || null;
188
+ this.config = normalize(options.config || {});
189
+ this.adapter = options.adapter || null;
190
+ this.agent = options.agent || 'henri-webhooks';
191
+ this.ownsAdapter = false;
192
+ this.store = null;
193
+ this.started = false;
194
+ this.keys = null;
195
+ }
196
+
197
+ /**
198
+ * Says something, when there is a pen to say it with
199
+ *
200
+ * @param {string} level info, warn or error
201
+ * @param {...*} args What to say
202
+ * @returns {void}
203
+ * @memberof Webhooks
204
+ */
205
+ log(level, ...args) {
206
+ if (this.pen && typeof this.pen[level] === 'function') {
207
+ this.pen[level]('webhooks', ...args);
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Prepares the table and the key the secrets are sealed with
213
+ *
214
+ * @param {object} [options={}] `install`, overriding the configuration
215
+ * @returns {Promise<Webhooks>} This instance
216
+ * @throws {WebhookError} When the store cannot hold the endpoints
217
+ * @memberof Webhooks
218
+ */
219
+ async start(options = {}) {
220
+ const adapter = this.resolveAdapter();
221
+
222
+ if (this.ownsAdapter) {
223
+ await adapter.start();
224
+ }
225
+
226
+ this.store = storeFor(adapter, this.config.tables);
227
+ this.keys = keyring(this.secretOf());
228
+
229
+ if (!this.keys) {
230
+ this.log(
231
+ 'warn',
232
+ 'no "secret" in the configuration: the endpoint signing secrets are stored as they are, not encrypted'
233
+ );
234
+ }
235
+
236
+ const install =
237
+ typeof options.install === 'boolean'
238
+ ? options.install
239
+ : this.config.install;
240
+
241
+ if (install) {
242
+ try {
243
+ await this.store.install();
244
+ } catch (error) {
245
+ throw new WebhookError(
246
+ 'HENRI_WEBHOOK_UNSUPPORTED_STORE',
247
+ `@usehenri/webhooks: unable to create the endpoints table in the "${this.config.store}" store: ${error.message}`,
248
+ {
249
+ cause: error,
250
+ hint: 'Run `henri webhooks:install` once with a user that may create tables, then set "install": false in the webhooks configuration',
251
+ }
252
+ );
253
+ }
254
+ }
255
+
256
+ this.started = true;
257
+
258
+ return this;
259
+ }
260
+
261
+ /**
262
+ * Lets go of what this instance opened
263
+ *
264
+ * @returns {Promise<void>} Resolves when done
265
+ * @memberof Webhooks
266
+ */
267
+ async stop() {
268
+ this.started = false;
269
+ }
270
+
271
+ /**
272
+ * The application's own secret, which seals the endpoints' secrets
273
+ *
274
+ * @returns {?string} `config.secret`
275
+ * @memberof Webhooks
276
+ */
277
+ secretOf() {
278
+ const { config } = this.henri || {};
279
+
280
+ if (config && typeof config.get === 'function' && config.has('secret')) {
281
+ return config.get('secret');
282
+ }
283
+
284
+ return null;
285
+ }
286
+
287
+ /**
288
+ * The store adapter holding the endpoints
289
+ *
290
+ * @returns {object} A henri store adapter
291
+ * @throws {WebhookError} STORE_MISSING when the store is unknown
292
+ * @memberof Webhooks
293
+ */
294
+ resolveAdapter() {
295
+ if (this.adapter) {
296
+ return this.adapter;
297
+ }
298
+
299
+ const model = this.henri && this.henri.model;
300
+ const stores = (model && model.stores) || {};
301
+ const name = this.config.store;
302
+
303
+ if (stores[name]) {
304
+ return stores[name];
305
+ }
306
+
307
+ if (model && typeof model.getStore === 'function') {
308
+ let store = null;
309
+
310
+ try {
311
+ store = model.getStore(name);
312
+ } catch (error) {
313
+ debug('store %s cannot be built: %s', name, error.message);
314
+ }
315
+
316
+ if (store) {
317
+ this.ownsAdapter = true;
318
+
319
+ return store;
320
+ }
321
+ }
322
+
323
+ throw new WebhookError(
324
+ 'HENRI_WEBHOOK_STORE_MISSING',
325
+ `@usehenri/webhooks: no store named "${name}" in the configuration`,
326
+ {
327
+ hint: 'Set webhooks.store to one of the stores of config/default.json',
328
+ }
329
+ );
330
+ }
331
+
332
+ /**
333
+ * The store, once this is started
334
+ *
335
+ * @returns {object} The store backend
336
+ * @throws {WebhookError} NOT_STARTED before start()
337
+ * @memberof Webhooks
338
+ */
339
+ storeOrDie() {
340
+ if (!this.store) {
341
+ throw new WebhookError(
342
+ 'HENRI_WEBHOOK_NOT_STARTED',
343
+ '@usehenri/webhooks: the endpoints are not ready',
344
+ {
345
+ hint: 'henri starts them for you; outside of henri, call await webhooks.start()',
346
+ }
347
+ );
348
+ }
349
+
350
+ return this.store;
351
+ }
352
+
353
+ /**
354
+ * The queue, or a readable error
355
+ *
356
+ * @returns {object} `henri.jobs`
357
+ * @throws {Error} HENRI_JOB_QUEUE_UNAVAILABLE without a running queue
358
+ * @memberof Webhooks
359
+ */
360
+ queue() {
361
+ const { jobs } = this.henri || {};
362
+
363
+ if (jobs && jobs.enabled) {
364
+ return jobs;
365
+ }
366
+
367
+ throw coded(
368
+ 'HENRI_JOB_QUEUE_UNAVAILABLE',
369
+ '@usehenri/webhooks: a delivery is a job, and this application has no running queue',
370
+ {
371
+ hint: 'Install @usehenri/jobs, add a "jobs" block to config/default.json, and run a worker with `henri jobs`',
372
+ retryable: false,
373
+ }
374
+ );
375
+ }
376
+
377
+ /**
378
+ * A stored row, as the API hands it out
379
+ *
380
+ * The secrets never come out here: `secrets()` is what reveals them, and
381
+ * it is the only thing that does.
382
+ *
383
+ * @param {?object} row A row of the table
384
+ * @returns {?object} The endpoint
385
+ * @memberof Webhooks
386
+ */
387
+ toEndpoint(row) {
388
+ if (!row) {
389
+ return null;
390
+ }
391
+
392
+ const disabledAt = toNumber(row.disabled_at);
393
+
394
+ return {
395
+ createdAt: at(row.created_at),
396
+ description: row.description || null,
397
+ disabled: disabledAt !== null,
398
+ disabledAt: at(row.disabled_at),
399
+ disabledReason: row.disabled_reason || null,
400
+ events: parse(row.events, []),
401
+ headers: parse(row.headers, {}) || {},
402
+ id: row.id,
403
+ owner: row.owner || null,
404
+ secrets: (parse(row.secrets, []) || []).map((record) => ({
405
+ createdAt: at(record.createdAt),
406
+ expiresAt: at(record.expiresAt),
407
+ id: record.id,
408
+ scheme: record.scheme,
409
+ })),
410
+ updatedAt: at(row.updated_at),
411
+ url: row.url,
412
+ };
413
+ }
414
+
415
+ /**
416
+ * Checks what a caller wants to subscribe to
417
+ *
418
+ * @param {*} events The subscriptions
419
+ * @returns {Array<string>} The patterns
420
+ * @throws {WebhookError} INVALID_ENDPOINT when they are not patterns
421
+ * @memberof Webhooks
422
+ */
423
+ events(events) {
424
+ const list = Array.isArray(events) ? events : [events];
425
+ const patterns = list
426
+ .filter((entry) => typeof entry === 'string')
427
+ .map((entry) => entry.trim())
428
+ .filter(Boolean);
429
+
430
+ if (patterns.length === 0 || patterns.length > MAX_EVENTS) {
431
+ throw new WebhookError(
432
+ 'HENRI_WEBHOOK_INVALID_ENDPOINT',
433
+ `an endpoint subscribes to between 1 and ${MAX_EVENTS} events`,
434
+ { hint: 'events: ["invoice.paid", "invoice.*"], or ["*"] for all' }
435
+ );
436
+ }
437
+
438
+ for (const pattern of patterns) {
439
+ if (!PATTERN.test(pattern)) {
440
+ throw new WebhookError(
441
+ 'HENRI_WEBHOOK_INVALID_ENDPOINT',
442
+ `"${pattern}" is not an event pattern`,
443
+ {
444
+ hint: 'An event name (`invoice.paid`), a family (`invoice.*`) or `*`',
445
+ }
446
+ );
447
+ }
448
+ }
449
+
450
+ return [...new Set(patterns)];
451
+ }
452
+
453
+ /**
454
+ * Checks the headers an endpoint asked to carry
455
+ *
456
+ * A receiver may need one of its own (a routing tag, a token its gateway
457
+ * expects). It may not set the ones the signature owns, the ones the
458
+ * request owns, or a hop-by-hop one: a header a receiver could set to
459
+ * shadow `webhook-signature` would be a hole with a form in front of it.
460
+ *
461
+ * @param {*} headers What the caller asked for
462
+ * @returns {object} The headers
463
+ * @throws {WebhookError} INVALID_ENDPOINT when one is refused
464
+ * @memberof Webhooks
465
+ */
466
+ headers(headers) {
467
+ if (!headers) {
468
+ return {};
469
+ }
470
+
471
+ if (typeof headers !== 'object' || Array.isArray(headers)) {
472
+ throw new WebhookError(
473
+ 'HENRI_WEBHOOK_INVALID_ENDPOINT',
474
+ 'the headers of an endpoint are an object of names and values'
475
+ );
476
+ }
477
+
478
+ const names = Object.keys(headers);
479
+
480
+ if (names.length > MAX_HEADERS) {
481
+ throw new WebhookError(
482
+ 'HENRI_WEBHOOK_INVALID_ENDPOINT',
483
+ `an endpoint carries at most ${MAX_HEADERS} headers of its own`
484
+ );
485
+ }
486
+
487
+ const checked = {};
488
+
489
+ for (const name of names) {
490
+ const lowered = name.toLowerCase();
491
+
492
+ if (
493
+ RESERVED.includes(lowered) ||
494
+ !/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/u.test(name)
495
+ ) {
496
+ throw new WebhookError(
497
+ 'HENRI_WEBHOOK_INVALID_ENDPOINT',
498
+ `an endpoint may not set the "${name}" header`,
499
+ {
500
+ hint: `henri owns ${RESERVED.join(', ')}`,
501
+ }
502
+ );
503
+ }
504
+
505
+ checked[lowered] = String(headers[name]);
506
+ }
507
+
508
+ return checked;
509
+ }
510
+
511
+ /**
512
+ * Checks the tenant an endpoint belongs to
513
+ *
514
+ * An owner is an identifier, and an identifier is never truncated to fit:
515
+ * two tenants sharing a long prefix would then share their endpoints,
516
+ * which is the failure this key exists to prevent. Prose (`description`,
517
+ * a disabled `reason`) is cut to fit; this is refused.
518
+ *
519
+ * @param {*} owner What the caller asked for
520
+ * @returns {?string} The owner, or null
521
+ * @throws {WebhookError} INVALID_ENDPOINT when it cannot be stored whole
522
+ * @memberof Webhooks
523
+ */
524
+ owner(owner) {
525
+ if (owner === null || typeof owner === 'undefined' || owner === '') {
526
+ return null;
527
+ }
528
+
529
+ const value = String(owner);
530
+
531
+ if (value.length > MAX_OWNER) {
532
+ throw new WebhookError(
533
+ 'HENRI_WEBHOOK_INVALID_ENDPOINT',
534
+ `an owner is at most ${MAX_OWNER} characters, and this one is ${value.length}`,
535
+ {
536
+ hint: 'An owner is the tenant identifier, and it is never truncated: two tenants sharing a prefix would share their endpoints',
537
+ }
538
+ );
539
+ }
540
+
541
+ return value;
542
+ }
543
+
544
+ /**
545
+ * Registers an endpoint, and hands its secret over once
546
+ *
547
+ * The url's shape is checked here; the address it resolves to is checked
548
+ * when a delivery is made, and only then, because DNS answers differently
549
+ * later.
550
+ *
551
+ * @param {object} options Options
552
+ * @param {string} options.url Where the deliveries go
553
+ * @param {(string|Array<string>)} options.events What it subscribes to
554
+ * @param {string} [options.owner] The tenant this endpoint belongs to
555
+ * @param {string} [options.description] What it is, for the operator
556
+ * @param {object} [options.headers] Headers of its own
557
+ * @param {string} [options.secret] A secret of your own; henri generates
558
+ * one otherwise, which is what you want
559
+ * @returns {Promise<object>} The endpoint, with `secret`
560
+ * @throws {WebhookError} INVALID_ENDPOINT when something is refused
561
+ * @memberof Webhooks
562
+ */
563
+ async register(options = {}) {
564
+ const store = this.storeOrDie();
565
+ const now = Date.now();
566
+ // The scheme, the credentials and the shape, here; the address, later
567
+ const url = parseUrl(options.url, { allowHttp: this.config.allowHttp });
568
+ const record = fresh({ now, secret: options.secret });
569
+ const row = {
570
+ created_at: now,
571
+ description: options.description
572
+ ? String(options.description).slice(0, 190)
573
+ : null,
574
+ disabled_at: null,
575
+ disabled_reason: null,
576
+ events: JSON.stringify(this.events(options.events)),
577
+ headers: JSON.stringify(this.headers(options.headers)),
578
+ id: options.id || randomUUID(),
579
+ owner: this.owner(options.owner),
580
+ secrets: JSON.stringify([
581
+ { ...record, key: seal(record.key, this.keys) },
582
+ ]),
583
+ updated_at: now,
584
+ url: url.href,
585
+ };
586
+
587
+ const stored = await store.insert(row);
588
+
589
+ await this.forget(row.owner);
590
+ this.log('info', 'endpoint registered', row.id, url.href);
591
+
592
+ return { ...this.toEndpoint(stored), secret: record.key };
593
+ }
594
+
595
+ /**
596
+ * One endpoint
597
+ *
598
+ * @param {string} id The endpoint id
599
+ * @returns {Promise<?object>} The endpoint, or null
600
+ * @memberof Webhooks
601
+ */
602
+ async endpoint(id) {
603
+ return this.toEndpoint(await this.storeOrDie().find(id));
604
+ }
605
+
606
+ /**
607
+ * One endpoint, or a readable error
608
+ *
609
+ * @param {string} id The endpoint id
610
+ * @returns {Promise<object>} The row
611
+ * @throws {WebhookError} UNKNOWN when there is no such endpoint
612
+ * @memberof Webhooks
613
+ */
614
+ async rowOf(id) {
615
+ const row = await this.storeOrDie().find(id);
616
+
617
+ if (!row) {
618
+ throw new WebhookError(
619
+ 'HENRI_WEBHOOK_UNKNOWN',
620
+ `no webhook endpoint with id "${id}"`,
621
+ { hint: '`henri webhooks:list` shows the endpoints', retryable: false }
622
+ );
623
+ }
624
+
625
+ return row;
626
+ }
627
+
628
+ /**
629
+ * The endpoints, newest last
630
+ *
631
+ * @param {object} [filter={}] `owner`, `disabled`, `limit`, `offset`
632
+ * @returns {Promise<Array<object>>} The endpoints
633
+ * @memberof Webhooks
634
+ */
635
+ async endpoints(filter = {}) {
636
+ const rows = await this.storeOrDie().list(filter);
637
+
638
+ return rows.map((row) => this.toEndpoint(row));
639
+ }
640
+
641
+ /**
642
+ * The active secrets of an endpoint, in the clear
643
+ *
644
+ * The only thing that reveals them. An operator needs it when a receiver
645
+ * lost the secret it was given, which is the moment the alternative is a
646
+ * rotation nobody planned.
647
+ *
648
+ * @param {string} id The endpoint id
649
+ * @returns {Promise<Array<string>>} The secrets that still sign
650
+ * @throws {WebhookError} UNKNOWN, or SECRET_UNREADABLE
651
+ * @memberof Webhooks
652
+ */
653
+ async secrets(id) {
654
+ const row = await this.rowOf(id);
655
+
656
+ return active(parse(row.secrets, []), Date.now()).map((record) =>
657
+ open(record.key, this.keys)
658
+ );
659
+ }
660
+
661
+ /**
662
+ * Changes an endpoint
663
+ *
664
+ * @param {string} id The endpoint id
665
+ * @param {object} [changes={}] `url`, `events`, `description`, `headers`,
666
+ * `owner`
667
+ * @returns {Promise<object>} The endpoint
668
+ * @throws {WebhookError} UNKNOWN, or INVALID_ENDPOINT
669
+ * @memberof Webhooks
670
+ */
671
+ async update(id, changes = {}) {
672
+ const row = await this.rowOf(id);
673
+ const written = { updated_at: Date.now() };
674
+
675
+ if (typeof changes.url === 'string') {
676
+ written.url = parseUrl(changes.url, {
677
+ allowHttp: this.config.allowHttp,
678
+ }).href;
679
+ }
680
+
681
+ if (typeof changes.events !== 'undefined') {
682
+ written.events = JSON.stringify(this.events(changes.events));
683
+ }
684
+
685
+ if (typeof changes.headers !== 'undefined') {
686
+ written.headers = JSON.stringify(this.headers(changes.headers));
687
+ }
688
+
689
+ if (typeof changes.description !== 'undefined') {
690
+ written.description = changes.description
691
+ ? String(changes.description).slice(0, 190)
692
+ : null;
693
+ }
694
+
695
+ if (typeof changes.owner !== 'undefined') {
696
+ written.owner = this.owner(changes.owner);
697
+ }
698
+
699
+ const stored = await this.storeOrDie().update(id, written);
700
+
701
+ await this.forget(row.owner);
702
+ await this.forget(written.owner);
703
+
704
+ return this.toEndpoint(stored);
705
+ }
706
+
707
+ /**
708
+ * Gives an endpoint a new secret, the old one expiring after a grace
709
+ *
710
+ * Both secrets sign while the grace lasts, so the receiver has that long
711
+ * to install the new one without dropping a delivery. `grace: 0` retires
712
+ * the old secret at once, which is what a leak calls for.
713
+ *
714
+ * @param {string} id The endpoint id
715
+ * @param {object} [options={}] `grace` (milliseconds), `secret`
716
+ * @returns {Promise<object>} The endpoint, with the new `secret`
717
+ * @throws {WebhookError} UNKNOWN when there is no such endpoint
718
+ * @memberof Webhooks
719
+ */
720
+ async rotate(id, options = {}) {
721
+ const row = await this.rowOf(id);
722
+ const now = Date.now();
723
+ const records = (parse(row.secrets, []) || []).map((record) => ({
724
+ ...record,
725
+ key: open(record.key, this.keys),
726
+ }));
727
+ const next = rotate(records, { ...options, now });
728
+ const stored = await this.storeOrDie().update(id, {
729
+ secrets: JSON.stringify(
730
+ next.map((record) => ({ ...record, key: seal(record.key, this.keys) }))
731
+ ),
732
+ updated_at: now,
733
+ });
734
+
735
+ await this.forget(row.owner);
736
+ this.log('info', 'endpoint secret rotated', id);
737
+
738
+ return { ...this.toEndpoint(stored), secret: next[0].key };
739
+ }
740
+
741
+ /**
742
+ * Stops sending to an endpoint
743
+ *
744
+ * @param {string} id The endpoint id
745
+ * @param {object} [options={}] `reason`
746
+ * @returns {Promise<object>} The endpoint
747
+ * @throws {WebhookError} UNKNOWN when there is no such endpoint
748
+ * @memberof Webhooks
749
+ */
750
+ async disable(id, options = {}) {
751
+ const row = await this.rowOf(id);
752
+ const stored = await this.storeOrDie().update(id, {
753
+ disabled_at: Date.now(),
754
+ disabled_reason: options.reason
755
+ ? String(options.reason).slice(0, 190)
756
+ : null,
757
+ updated_at: Date.now(),
758
+ });
759
+
760
+ await this.forget(row.owner);
761
+ this.log('warn', 'endpoint disabled', id, options.reason || '');
762
+
763
+ return this.toEndpoint(stored);
764
+ }
765
+
766
+ /**
767
+ * Sends to an endpoint again
768
+ *
769
+ * @param {string} id The endpoint id
770
+ * @returns {Promise<object>} The endpoint
771
+ * @throws {WebhookError} UNKNOWN when there is no such endpoint
772
+ * @memberof Webhooks
773
+ */
774
+ async enable(id) {
775
+ const row = await this.rowOf(id);
776
+ const stored = await this.storeOrDie().update(id, {
777
+ disabled_at: null,
778
+ disabled_reason: null,
779
+ updated_at: Date.now(),
780
+ });
781
+
782
+ await this.forget(row.owner);
783
+
784
+ return this.toEndpoint(stored);
785
+ }
786
+
787
+ /**
788
+ * Forgets an endpoint for good
789
+ *
790
+ * The deliveries already queued for it stop on their own: the job reads
791
+ * the endpoint back and finds nothing.
792
+ *
793
+ * @param {string} id The endpoint id
794
+ * @returns {Promise<boolean>} Whether there was one to remove
795
+ * @memberof Webhooks
796
+ */
797
+ async remove(id) {
798
+ const row = await this.storeOrDie().find(id);
799
+
800
+ if (!row) {
801
+ return false;
802
+ }
803
+
804
+ await this.storeOrDie().remove(id);
805
+ await this.forget(row.owner);
806
+
807
+ return true;
808
+ }
809
+
810
+ /**
811
+ * The cache key of one owner's endpoints
812
+ *
813
+ * @param {?string} owner The owner
814
+ * @returns {Array<string>} The key
815
+ * @memberof Webhooks
816
+ */
817
+ cacheKey(owner) {
818
+ return ['henri', 'webhooks', 'endpoints', owner || '-'];
819
+ }
820
+
821
+ /**
822
+ * Drops the cached lookup of one owner
823
+ *
824
+ * @param {?string} owner The owner
825
+ * @returns {Promise<void>} Resolves when it is gone
826
+ * @memberof Webhooks
827
+ */
828
+ async forget(owner) {
829
+ const { cache } = this.henri || {};
830
+
831
+ if (cache && typeof cache.delete === 'function') {
832
+ await cache.delete(this.cacheKey(owner)).catch(() => null);
833
+ }
834
+ }
835
+
836
+ /**
837
+ * What one owner subscribes to, cached
838
+ *
839
+ * Only the id, the owner and the patterns are cached: a signing secret
840
+ * never reaches the cache, whether it is this process's memory or the
841
+ * Redis of `config.shared`. The delivery job reads the endpoint back from
842
+ * the database when it is about to sign, which is also what makes a
843
+ * disabled endpoint stop being sent to at once.
844
+ *
845
+ * @param {?string} owner The owner
846
+ * @returns {Promise<Array<object>>} `{ events, id, owner }` entries
847
+ * @memberof Webhooks
848
+ */
849
+ async subscriptions(owner) {
850
+ const load = async () => {
851
+ const rows = await this.storeOrDie().list({
852
+ disabled: false,
853
+ limit: this.config.maxFanout + 1,
854
+ owner,
855
+ });
856
+
857
+ return rows.map((row) => ({
858
+ events: parse(row.events, []),
859
+ id: row.id,
860
+ owner: row.owner || null,
861
+ }));
862
+ };
863
+ const { cache } = this.henri || {};
864
+
865
+ if (!cache || typeof cache.fetch !== 'function') {
866
+ return load();
867
+ }
868
+
869
+ return cache.fetch(this.cacheKey(owner), { ttl: CACHE_TTL }, load);
870
+ }
871
+
872
+ /**
873
+ * Sends an event to every endpoint subscribed to it
874
+ *
875
+ * Nothing is sent here: one row per endpoint is written to the queue and
876
+ * the call returns. A runner (`henri jobs`) delivers them.
877
+ *
878
+ * @param {string} event The event name (`invoice.paid`)
879
+ * @param {*} [data=null] What the receivers get, under `data`
880
+ * @param {object} [options={}] Options
881
+ * @param {string} [options.owner] Only this tenant's endpoints; defaults
882
+ * to the tenant of the request or job this is emitted from, when the
883
+ * application is multi-tenant (`henri.tenancy`)
884
+ * @param {(number|string)} [options.wait] Deliver that much later
885
+ * @returns {Promise<Array<object>>} One `{ id, endpoint, job }` per
886
+ * delivery enqueued
887
+ * @throws {WebhookError} INVALID_EVENT, or FANOUT_TOO_LARGE
888
+ * @memberof Webhooks
889
+ */
890
+ async emit(event, data = null, options = {}) {
891
+ if (typeof event !== 'string' || !EVENT.test(event)) {
892
+ throw new WebhookError(
893
+ 'HENRI_WEBHOOK_INVALID_EVENT',
894
+ `"${event}" is not an event name`,
895
+ { hint: 'Letters, digits, - and _, in dot separated segments' }
896
+ );
897
+ }
898
+
899
+ // The `owner` of an endpoint has been the tenant since this package
900
+ // shipped, and `henri.tenancy` is now what says which tenant a request
901
+ // or a job is: an `emit` inside one goes to that tenant's endpoints
902
+ // without the caller repeating it. Naming an owner still wins, and an
903
+ // application that is not multi-tenant is exactly where it was
904
+ const owner = this.ownerOf(options);
905
+ const found = await this.subscriptions(owner);
906
+ const endpoints = found.filter((entry) => subscribed(entry.events, event));
907
+
908
+ if (endpoints.length > this.config.maxFanout) {
909
+ throw new WebhookError(
910
+ 'HENRI_WEBHOOK_FANOUT_TOO_LARGE',
911
+ `${event} has ${endpoints.length} endpoints, over the ${this.config.maxFanout} of webhooks.maxFanout`,
912
+ {
913
+ hint: 'Emit from a job rather than from a request, or raise webhooks.maxFanout',
914
+ }
915
+ );
916
+ }
917
+
918
+ const deliveries = [];
919
+
920
+ for (const endpoint of endpoints) {
921
+ deliveries.push(await this.enqueue(endpoint.id, event, data, options));
922
+ }
923
+
924
+ debug('%s -> %d endpoint(s)', event, deliveries.length);
925
+
926
+ return deliveries;
927
+ }
928
+
929
+ /**
930
+ * The owner an `emit` means: what it named, else the tenant in scope.
931
+ *
932
+ * Explicit wins, including an explicit `null` -- "the endpoints that
933
+ * belong to nobody" is a thing to mean, and it is what a platform-wide
934
+ * event is. Without the key at all, a multi-tenant application gets the
935
+ * tenant of whatever is running, which is the answer that makes
936
+ * forgetting safe rather than cross-tenant.
937
+ *
938
+ * @param {object} options What `emit()` was given
939
+ * @returns {?string} The owner
940
+ * @memberof Webhooks
941
+ */
942
+ ownerOf(options = {}) {
943
+ if (Object.prototype.hasOwnProperty.call(options, 'owner')) {
944
+ return options.owner === null || typeof options.owner === 'undefined'
945
+ ? null
946
+ : String(options.owner);
947
+ }
948
+
949
+ const tenancy = this.henri && this.henri.tenancy;
950
+
951
+ return (tenancy && tenancy.enabled && tenancy.current()) || null;
952
+ }
953
+
954
+ /**
955
+ * Enqueues one delivery to one endpoint
956
+ *
957
+ * The body is serialized here, once, and stored with the job: every
958
+ * attempt then signs the same bytes, and the delivery id a receiver
959
+ * deduplicates on is the same on every attempt too. The timestamp is not:
960
+ * it is stamped when the request goes out, so a retry six hours later is
961
+ * still inside the receiver's window.
962
+ *
963
+ * @param {string} id The endpoint id
964
+ * @param {string} event The event name
965
+ * @param {*} [data=null] What the receiver gets, under `data`
966
+ * @param {object} [options={}] `wait`, `at`
967
+ * @returns {Promise<object>} `{ id, endpoint, event, job }`
968
+ * @memberof Webhooks
969
+ */
970
+ async enqueue(id, event, data = null, options = {}) {
971
+ const delivery = randomUUID();
972
+ const body = JSON.stringify({
973
+ data,
974
+ id: delivery,
975
+ timestamp: new Date().toISOString(),
976
+ type: event,
977
+ });
978
+ // The request that caused the emit is over by the time the delivery
979
+ // goes out, so the id has to travel with the job: without it the call
980
+ // log would hold the outbound call and nothing to join it to
981
+ const { calls } = this.henri || {};
982
+ const job = await this.queue().perform(
983
+ DELIVERY_JOB,
984
+ {
985
+ body,
986
+ endpoint: id,
987
+ event,
988
+ id: delivery,
989
+ requestId: (calls && calls.requestId()) || null,
990
+ },
991
+ {
992
+ at: options.at,
993
+ queue: this.config.queue,
994
+ wait: options.wait,
995
+ }
996
+ );
997
+
998
+ return { endpoint: id, event, id: delivery, job: job.id };
999
+ }
1000
+
1001
+ /**
1002
+ * Performs one delivery: what the `henri/webhook` job does
1003
+ *
1004
+ * @param {object} args `{ body, endpoint, event, id }`
1005
+ * @param {object} [context={}] The job context
1006
+ * @returns {Promise<object>} What happened
1007
+ * @throws {Error} Whatever the delivery failed with, `retryable` set
1008
+ * @memberof Webhooks
1009
+ */
1010
+ async perform(args, context = {}) {
1011
+ const row = await this.storeOrDie().find(args.endpoint);
1012
+
1013
+ // The endpoint was deleted, or disabled, while this was waiting: both
1014
+ // are somebody saying "stop", so the delivery ends here and says so
1015
+ if (!row) {
1016
+ return { endpoint: args.endpoint, id: args.id, skipped: 'removed' };
1017
+ }
1018
+
1019
+ if (toNumber(row.disabled_at) !== null) {
1020
+ return { endpoint: args.endpoint, id: args.id, skipped: 'disabled' };
1021
+ }
1022
+
1023
+ const records = active(parse(row.secrets, []) || [], Date.now());
1024
+ const secrets = records.map((record) => open(record.key, this.keys));
1025
+ const headers = {
1026
+ ...(parse(row.headers, {}) || {}),
1027
+ ...headersFor({
1028
+ agent: this.agent,
1029
+ body: args.body,
1030
+ id: args.id,
1031
+ secrets,
1032
+ }),
1033
+ };
1034
+
1035
+ const telemetry = this.henri && this.henri.telemetry;
1036
+ const attempt = (context.job && context.job.attempt) || 1;
1037
+ const send = () => this.send(row, args, headers, attempt);
1038
+
1039
+ if (!telemetry || typeof telemetry.span !== 'function') {
1040
+ return send();
1041
+ }
1042
+
1043
+ // The endpoint is named by its id, never by its url: a url is registered
1044
+ // by a tenant and may carry a token in its path, and the id is what
1045
+ // `henri webhooks:show` takes anyway (see base/telemetry.js in core)
1046
+ return telemetry.span(
1047
+ 'henri.webhook.deliver',
1048
+ {
1049
+ attributes: {
1050
+ 'henri.webhook.attempt': attempt,
1051
+ 'henri.webhook.endpoint': row.id,
1052
+ 'henri.webhook.event': args.event,
1053
+ },
1054
+ boundary: 'webhooks',
1055
+ kind: 'client',
1056
+ },
1057
+ send
1058
+ );
1059
+ }
1060
+
1061
+ /**
1062
+ * Starts timing one delivery, when the application keeps a call log.
1063
+ *
1064
+ * The body is the envelope this package built, parsed back into the
1065
+ * object it was: the call log stores a body it can walk and redact, and
1066
+ * a JSON string is not one. What the receiver answered is deliberately
1067
+ * *not* recorded -- it is untrusted text nothing can redact, and the
1068
+ * queue's own job row already holds the excerpt an operator reads.
1069
+ *
1070
+ * @param {string} url Where the delivery goes
1071
+ * @param {object} args The job arguments
1072
+ * @param {object} sent The headers of the request
1073
+ * @returns {Function} The finisher (a no-op without a call log)
1074
+ * @memberof Webhooks
1075
+ */
1076
+ tracked(url, args, sent) {
1077
+ const { calls } = this.henri || {};
1078
+
1079
+ if (!calls || !calls.enabled) {
1080
+ return () => null;
1081
+ }
1082
+
1083
+ let body;
1084
+
1085
+ try {
1086
+ body = JSON.parse(args.body);
1087
+ } catch (error) {
1088
+ body = null;
1089
+ }
1090
+
1091
+ return calls.track({
1092
+ method: 'POST',
1093
+ request: { body, headers: sent },
1094
+ requestId: args.requestId || null,
1095
+ service: 'webhooks',
1096
+ url,
1097
+ });
1098
+ }
1099
+
1100
+ /**
1101
+ * The request itself, inside whatever span wraps it
1102
+ *
1103
+ * Split out so `traceparent` is written *inside* the span: what a receiver
1104
+ * reads then names this delivery attempt, which is the only useful parent
1105
+ * it could have. `inject()` writes nothing when henri is not tracing or
1106
+ * when `telemetry.propagate` is false.
1107
+ *
1108
+ * @param {object} row The endpoint
1109
+ * @param {object} args `{ body, endpoint, event, id }`
1110
+ * @param {object} headers The headers, signature included
1111
+ * @param {number} attempt Which attempt this is
1112
+ * @returns {Promise<object>} What happened
1113
+ * @throws {Error} Whatever the delivery failed with, `retryable` set
1114
+ * @memberof Webhooks
1115
+ */
1116
+ async send(row, args, headers, attempt) {
1117
+ const telemetry = this.henri && this.henri.telemetry;
1118
+
1119
+ if (telemetry && typeof telemetry.inject === 'function') {
1120
+ telemetry.inject(headers);
1121
+ }
1122
+
1123
+ // The outbound call log records the same attempt the span times, and
1124
+ // records it here rather than in `perform` so that `traceparent` is
1125
+ // already in the headers it holds
1126
+ const finish = this.tracked(row.url, args, headers);
1127
+
1128
+ try {
1129
+ const answer = await deliver({
1130
+ allowHttp: this.config.allowHttp,
1131
+ allowPrivate: this.config.allowPrivate,
1132
+ body: args.body,
1133
+ headers,
1134
+ timeout: this.config.timeout,
1135
+ url: row.url,
1136
+ });
1137
+
1138
+ finish({
1139
+ meta: { attempt, endpoint: row.id, event: args.event },
1140
+ status: answer.status,
1141
+ });
1142
+
1143
+ this.log(
1144
+ 'info',
1145
+ args.event,
1146
+ args.id,
1147
+ `-> ${row.url} ${answer.status} in ${answer.duration}ms`
1148
+ );
1149
+
1150
+ return {
1151
+ address: answer.address,
1152
+ attempt,
1153
+ duration: answer.duration,
1154
+ endpoint: row.id,
1155
+ event: args.event,
1156
+ id: args.id,
1157
+ status: answer.status,
1158
+ };
1159
+ } catch (error) {
1160
+ finish({
1161
+ error: error.code || error.name,
1162
+ meta: { attempt, endpoint: row.id, event: args.event },
1163
+ status: error.status || null,
1164
+ });
1165
+
1166
+ if (error.gone) {
1167
+ await this.disable(row.id, {
1168
+ reason: 'the receiver answered 410 Gone',
1169
+ });
1170
+ }
1171
+
1172
+ throw error;
1173
+ }
1174
+ }
1175
+
1176
+ /**
1177
+ * The endpoints and what the queue holds for them
1178
+ *
1179
+ * The delivery numbers are the queue's own: there is no second place they
1180
+ * could come from, and no second place to keep them in step.
1181
+ *
1182
+ * @returns {Promise<object>} `{ endpoints, queue, deliveries }`
1183
+ * @memberof Webhooks
1184
+ */
1185
+ async stats() {
1186
+ const [total, disabled] = await Promise.all([
1187
+ this.storeOrDie().count(),
1188
+ this.storeOrDie().count({ disabled: true }),
1189
+ ]);
1190
+ const { jobs } = this.henri || {};
1191
+ let deliveries = null;
1192
+
1193
+ if (jobs && jobs.enabled) {
1194
+ const found = await jobs.stats();
1195
+
1196
+ deliveries =
1197
+ found.queues.find((entry) => entry.queue === this.config.queue) || null;
1198
+ }
1199
+
1200
+ return {
1201
+ deliveries,
1202
+ endpoints: { disabled, enabled: total - disabled, total },
1203
+ queue: this.config.queue,
1204
+ };
1205
+ }
1206
+ }
1207
+
1208
+ module.exports = {
1209
+ CACHE_TTL,
1210
+ DELIVERY_JOB,
1211
+ EVENT,
1212
+ MAX_EVENTS,
1213
+ MAX_HEADERS,
1214
+ MAX_OWNER,
1215
+ PATTERN,
1216
+ RESERVED,
1217
+ Webhooks,
1218
+ subscribed,
1219
+ };