@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.
- package/CHANGELOG.md +115 -0
- package/LICENSE +21 -0
- package/README.md +8 -1
- package/index.js +48 -0
- package/module.js +8 -0
- package/package.json +51 -10
- package/src/address.js +280 -0
- package/src/config.js +85 -0
- package/src/deliver.js +271 -0
- package/src/errors.js +107 -0
- package/src/job.js +49 -0
- package/src/module.js +368 -0
- package/src/secrets.js +240 -0
- package/src/signature.js +330 -0
- package/src/store/index.js +41 -0
- package/src/store/mongo.js +229 -0
- package/src/store/schema.js +230 -0
- package/src/store/sql.js +450 -0
- package/src/webhooks.js +1219 -0
package/src/webhooks.js
ADDED
|
@@ -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
|
+
};
|