@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/signature.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
const { createHmac, randomBytes, timingSafeEqual } = require('crypto');
|
|
2
|
+
|
|
3
|
+
const { WebhookError, coded } = require('./errors');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The signature henri puts on every delivery.
|
|
7
|
+
*
|
|
8
|
+
* ## What is followed, and what is not
|
|
9
|
+
*
|
|
10
|
+
* The scheme is **Standard Webhooks** (https://www.standardwebhooks.com),
|
|
11
|
+
* the specification Svix wrote and a growing number of senders emit, and it
|
|
12
|
+
* is followed to the byte: the three headers are `webhook-id`,
|
|
13
|
+
* `webhook-timestamp` and `webhook-signature`, the signed content is
|
|
14
|
+
* `id.timestamp.body`, the algorithm is HMAC-SHA256, the signature is
|
|
15
|
+
* base64 with a `v1,` scheme prefix, and the secret is `whsec_` followed by
|
|
16
|
+
* the base64 of the key. A receiver that already has a Standard Webhooks
|
|
17
|
+
* library verifies henri with nothing written.
|
|
18
|
+
*
|
|
19
|
+
* That specification was picked over the two schemes most receivers have
|
|
20
|
+
* seen, on the strength of what they leave out:
|
|
21
|
+
*
|
|
22
|
+
* - **GitHub** sends `X-Hub-Signature-256: sha256=<hex of hmac(body)>`. The
|
|
23
|
+
* body and nothing else is signed: no timestamp, so a captured request
|
|
24
|
+
* replays for as long as the secret lives, and no delivery id, so a
|
|
25
|
+
* receiver cannot tell a retry from a duplicate.
|
|
26
|
+
* - **Shopify** sends `X-Shopify-Hmac-Sha256: <base64 of hmac(body)>`. Same
|
|
27
|
+
* omissions, and the encoding differs from GitHub's for the same
|
|
28
|
+
* algorithm, which is the single most common reason a hand-written
|
|
29
|
+
* verification fails on a genuine request.
|
|
30
|
+
* - **Stripe** does have a timestamp: `Stripe-Signature: t=<seconds>,
|
|
31
|
+
* v1=<hex of hmac("t.body")>`, with several `v1=` during a secret
|
|
32
|
+
* rotation and a tolerance of five minutes. It is the scheme this one is
|
|
33
|
+
* closest to.
|
|
34
|
+
*
|
|
35
|
+
* Where henri differs from Stripe, and why:
|
|
36
|
+
*
|
|
37
|
+
* - the timestamp is its own header rather than a field inside the
|
|
38
|
+
* signature header, so a receiver reads the recency check without parsing
|
|
39
|
+
* the signature first;
|
|
40
|
+
* - the **delivery id is part of the signed content**. Stripe re-signs a
|
|
41
|
+
* retry with a fresh timestamp and the event id only in the body, so
|
|
42
|
+
* deduplication means trusting the body before verifying it. henri keeps
|
|
43
|
+
* one `webhook-id` for every attempt of one delivery, signed, so
|
|
44
|
+
* "have I already processed this?" is answered from verified bytes;
|
|
45
|
+
* - the signature is base64, not hex, because the specification says so.
|
|
46
|
+
*
|
|
47
|
+
* ## Replay protection is part of this, not an extra
|
|
48
|
+
*
|
|
49
|
+
* The timestamp is signed, so it cannot be moved without the key. It is the
|
|
50
|
+
* moment of *this attempt*, not of the event: a delivery retried six hours
|
|
51
|
+
* later carries a fresh timestamp and a fresh signature, and stays inside
|
|
52
|
+
* the receiver's window. The event's own moment is in the signed body, as
|
|
53
|
+
* `timestamp`. A receiver refuses anything outside `TOLERANCE` (five
|
|
54
|
+
* minutes, Stripe's default and the one this package documents) and then
|
|
55
|
+
* refuses a `webhook-id` it has already answered. Neither half is enough on
|
|
56
|
+
* its own: the window bounds the replay, the id makes retries safe.
|
|
57
|
+
*
|
|
58
|
+
* ## Rotation
|
|
59
|
+
*
|
|
60
|
+
* Two things rotate, and the scheme identifier is what makes the second one
|
|
61
|
+
* possible:
|
|
62
|
+
*
|
|
63
|
+
* - the **key**: an endpoint may hold several secrets at once, and every
|
|
64
|
+
* one of them signs, so a receiver has a window to install the new key
|
|
65
|
+
* (`henri webhooks:rotate --grace 24h`). Stripe does the same thing.
|
|
66
|
+
* - the **scheme**: `v1` is HMAC-SHA256. A future asymmetric scheme is
|
|
67
|
+
* `v1a` in the specification, and a receiver that follows the advice
|
|
68
|
+
* below -- ignore every scheme you do not know -- keeps working while
|
|
69
|
+
* both are being sent.
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/** The scheme identifier of HMAC-SHA256, base64 */
|
|
73
|
+
const SCHEME = 'v1';
|
|
74
|
+
|
|
75
|
+
/** How a secret is written down, so a scanner can recognize one */
|
|
76
|
+
const PREFIX = 'whsec_';
|
|
77
|
+
|
|
78
|
+
/** How many bytes of key a generated secret carries */
|
|
79
|
+
const SECRET_BYTES = 32;
|
|
80
|
+
|
|
81
|
+
/** The shortest key this package will sign with (128 bits) */
|
|
82
|
+
const MIN_KEY_BYTES = 16;
|
|
83
|
+
|
|
84
|
+
/** The window a receiver should accept, in milliseconds */
|
|
85
|
+
const TOLERANCE = 300000;
|
|
86
|
+
|
|
87
|
+
/** The headers a delivery carries */
|
|
88
|
+
const HEADERS = {
|
|
89
|
+
id: 'webhook-id',
|
|
90
|
+
signature: 'webhook-signature',
|
|
91
|
+
timestamp: 'webhook-timestamp',
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A new endpoint secret
|
|
96
|
+
*
|
|
97
|
+
* @returns {string} `whsec_` and the base64 of 32 random bytes
|
|
98
|
+
*/
|
|
99
|
+
const generate = () =>
|
|
100
|
+
`${PREFIX}${randomBytes(SECRET_BYTES).toString('base64')}`;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The key a secret carries
|
|
104
|
+
*
|
|
105
|
+
* The `whsec_` prefix is a label, not key material: it is stripped, and
|
|
106
|
+
* what is left is base64 decoded. This is the step a hand-written verifier
|
|
107
|
+
* forgets, so the documentation says it twice and this function is
|
|
108
|
+
* exported.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} secret An endpoint secret
|
|
111
|
+
* @returns {Buffer} The key
|
|
112
|
+
* @throws {Error} HENRI_WEBHOOK_INVALID_SECRET when it carries no key
|
|
113
|
+
*/
|
|
114
|
+
const keyOf = (secret) => {
|
|
115
|
+
const text = String(secret || '');
|
|
116
|
+
const encoded = text.startsWith(PREFIX) ? text.slice(PREFIX.length) : text;
|
|
117
|
+
const key = Buffer.from(encoded, 'base64');
|
|
118
|
+
|
|
119
|
+
if (key.length < MIN_KEY_BYTES) {
|
|
120
|
+
throw coded(
|
|
121
|
+
'HENRI_WEBHOOK_INVALID_SECRET',
|
|
122
|
+
`@usehenri/webhooks: a signing secret is "${PREFIX}" and the base64 of at least ${MIN_KEY_BYTES} bytes`,
|
|
123
|
+
{
|
|
124
|
+
hint: 'Let henri generate one: henri webhooks:rotate <id>',
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return key;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* What is signed: the delivery id, the moment and the body, in that order
|
|
134
|
+
*
|
|
135
|
+
* The body is the bytes that are sent, never a re-serialization of them: a
|
|
136
|
+
* signature is sensitive to a re-ordered key or a changed space, and a
|
|
137
|
+
* receiver that parses before verifying has already lost.
|
|
138
|
+
*
|
|
139
|
+
* @param {object} delivery `id`, `timestamp` (unix seconds) and `body`
|
|
140
|
+
* @returns {string} The signed content
|
|
141
|
+
*/
|
|
142
|
+
const content = ({ body, id, timestamp }) => `${id}.${timestamp}.${body}`;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* One signature
|
|
146
|
+
*
|
|
147
|
+
* @param {object} delivery `id`, `timestamp`, `body` and `secret`
|
|
148
|
+
* @returns {string} `v1,<base64>`
|
|
149
|
+
*/
|
|
150
|
+
const signOne = ({ body, id, secret, timestamp }) =>
|
|
151
|
+
`${SCHEME},${createHmac('sha256', keyOf(secret))
|
|
152
|
+
.update(content({ body, id, timestamp }), 'utf8')
|
|
153
|
+
.digest('base64')}`;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The value of the `webhook-signature` header
|
|
157
|
+
*
|
|
158
|
+
* One signature per secret the endpoint holds, space delimited, which is
|
|
159
|
+
* what the specification's `signature(s)` means and what makes a key
|
|
160
|
+
* rotation invisible to a receiver that verifies them in turn.
|
|
161
|
+
*
|
|
162
|
+
* @param {object} delivery `id`, `timestamp`, `body` and `secrets`
|
|
163
|
+
* @returns {string} The header value
|
|
164
|
+
* @throws {WebhookError} NO_SECRET when the endpoint holds none
|
|
165
|
+
*/
|
|
166
|
+
const sign = ({ body, id, secrets, timestamp }) => {
|
|
167
|
+
const keys = (Array.isArray(secrets) ? secrets : [secrets]).filter(Boolean);
|
|
168
|
+
|
|
169
|
+
if (keys.length === 0) {
|
|
170
|
+
throw new WebhookError(
|
|
171
|
+
'HENRI_WEBHOOK_NO_SECRET',
|
|
172
|
+
'@usehenri/webhooks: the endpoint holds no signing secret',
|
|
173
|
+
{
|
|
174
|
+
hint: 'Give it one with: henri webhooks:rotate <id>',
|
|
175
|
+
retryable: false,
|
|
176
|
+
}
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return keys
|
|
181
|
+
.map((secret) => signOne({ body, id, secret, timestamp }))
|
|
182
|
+
.join(' ');
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The headers of a delivery, signature included
|
|
187
|
+
*
|
|
188
|
+
* Nothing a receiver could route on is sent outside the signature: the
|
|
189
|
+
* event type lives in the signed body, so an unsigned header cannot send a
|
|
190
|
+
* handler down the wrong branch.
|
|
191
|
+
*
|
|
192
|
+
* @param {object} delivery `id`, `body`, `secrets`, `now` and `agent`
|
|
193
|
+
* @returns {object} The headers, lowercased
|
|
194
|
+
*/
|
|
195
|
+
const headersFor = ({ agent, body, id, now = Date.now(), secrets }) => {
|
|
196
|
+
const timestamp = Math.floor(now / 1000);
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
'content-type': 'application/json; charset=utf-8',
|
|
200
|
+
'user-agent': agent || 'henri-webhooks',
|
|
201
|
+
[HEADERS.id]: id,
|
|
202
|
+
[HEADERS.signature]: sign({ body, id, secrets, timestamp }),
|
|
203
|
+
[HEADERS.timestamp]: String(timestamp),
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The signatures of a header value, unknown schemes dropped
|
|
209
|
+
*
|
|
210
|
+
* @param {string} value The `webhook-signature` header
|
|
211
|
+
* @returns {Array<Buffer>} The digests carrying the `v1` scheme
|
|
212
|
+
*/
|
|
213
|
+
const parse = (value) =>
|
|
214
|
+
String(value || '')
|
|
215
|
+
.split(/\s+/u)
|
|
216
|
+
.filter(Boolean)
|
|
217
|
+
.map((entry) => entry.split(','))
|
|
218
|
+
.filter(([scheme, digest]) => scheme === SCHEME && digest)
|
|
219
|
+
.map(([, digest]) => Buffer.from(digest, 'base64'));
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Whether two digests are the same, in constant time
|
|
223
|
+
*
|
|
224
|
+
* @param {Buffer} left One digest
|
|
225
|
+
* @param {Buffer} right The other
|
|
226
|
+
* @returns {boolean} Whether they match
|
|
227
|
+
*/
|
|
228
|
+
const same = (left, right) =>
|
|
229
|
+
left.length === right.length && timingSafeEqual(left, right);
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* One header of a request, whatever shape the headers arrived in
|
|
233
|
+
*
|
|
234
|
+
* @param {(object|Function)} headers An object, or a `name => value`
|
|
235
|
+
* @param {string} name The header name, lowercase
|
|
236
|
+
* @returns {?string} The value
|
|
237
|
+
*/
|
|
238
|
+
const headerOf = (headers, name) => {
|
|
239
|
+
if (typeof headers === 'function') {
|
|
240
|
+
return headers(name) || null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!headers || typeof headers !== 'object') {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const found =
|
|
248
|
+
typeof headers.get === 'function' ? headers.get(name) : headers[name];
|
|
249
|
+
|
|
250
|
+
return typeof found === 'undefined' || found === null ? null : String(found);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Verifies a delivery, the way a receiver does
|
|
255
|
+
*
|
|
256
|
+
* The package that signs is the package that verifies, so the snippet in
|
|
257
|
+
* the guide has something to be checked against: `__tests__/signature.spec.js`
|
|
258
|
+
* runs the documented steps over what `headersFor()` produced.
|
|
259
|
+
*
|
|
260
|
+
* @param {object} options Options
|
|
261
|
+
* @param {(string|Buffer)} options.body The raw body, as received
|
|
262
|
+
* @param {(object|Function)} options.headers The request headers
|
|
263
|
+
* @param {(string|Array<string>)} options.secret The endpoint secret(s)
|
|
264
|
+
* @param {number} [options.tolerance=TOLERANCE] The window, in milliseconds
|
|
265
|
+
* @param {number} [options.now] The moment to measure the window from
|
|
266
|
+
* @returns {object} `{ ok, reason, id, timestamp }`
|
|
267
|
+
*/
|
|
268
|
+
const verify = ({
|
|
269
|
+
body,
|
|
270
|
+
headers,
|
|
271
|
+
now = Date.now(),
|
|
272
|
+
secret,
|
|
273
|
+
tolerance = TOLERANCE,
|
|
274
|
+
}) => {
|
|
275
|
+
const id = headerOf(headers, HEADERS.id);
|
|
276
|
+
const stamp = headerOf(headers, HEADERS.timestamp);
|
|
277
|
+
const signatures = parse(headerOf(headers, HEADERS.signature));
|
|
278
|
+
const timestamp = Number(stamp);
|
|
279
|
+
|
|
280
|
+
if (!id || !stamp || signatures.length === 0) {
|
|
281
|
+
return { id, ok: false, reason: 'missing', timestamp: null };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!Number.isFinite(timestamp)) {
|
|
285
|
+
return { id, ok: false, reason: 'timestamp', timestamp: null };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (tolerance > 0 && Math.abs(now - timestamp * 1000) > tolerance) {
|
|
289
|
+
return { id, ok: false, reason: 'stale', timestamp };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const raw = Buffer.isBuffer(body) ? body.toString('utf8') : String(body);
|
|
293
|
+
const expected = (Array.isArray(secret) ? secret : [secret])
|
|
294
|
+
.filter(Boolean)
|
|
295
|
+
.map((key) =>
|
|
296
|
+
Buffer.from(
|
|
297
|
+
createHmac('sha256', keyOf(key))
|
|
298
|
+
.update(content({ body: raw, id, timestamp }), 'utf8')
|
|
299
|
+
.digest('base64'),
|
|
300
|
+
'base64'
|
|
301
|
+
)
|
|
302
|
+
);
|
|
303
|
+
const matched = expected.some((digest) =>
|
|
304
|
+
signatures.some((candidate) => same(candidate, digest))
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
id,
|
|
309
|
+
ok: matched,
|
|
310
|
+
reason: matched ? null : 'signature',
|
|
311
|
+
timestamp,
|
|
312
|
+
};
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
module.exports = {
|
|
316
|
+
HEADERS,
|
|
317
|
+
MIN_KEY_BYTES,
|
|
318
|
+
PREFIX,
|
|
319
|
+
SCHEME,
|
|
320
|
+
SECRET_BYTES,
|
|
321
|
+
TOLERANCE,
|
|
322
|
+
content,
|
|
323
|
+
generate,
|
|
324
|
+
headersFor,
|
|
325
|
+
keyOf,
|
|
326
|
+
parse,
|
|
327
|
+
sign,
|
|
328
|
+
signOne,
|
|
329
|
+
verify,
|
|
330
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const mongo = require('./mongo');
|
|
2
|
+
const sql = require('./sql');
|
|
3
|
+
|
|
4
|
+
const { WebhookError } = require('../errors');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Picks the backend of a store adapter
|
|
8
|
+
*
|
|
9
|
+
* The endpoints live in the database the application already runs, reached
|
|
10
|
+
* through the adapter's own surface: `query()` on the SQL adapters
|
|
11
|
+
* (sequelize and its dialect packages, drizzle), the MongoDB collections on
|
|
12
|
+
* the mongoose and disk adapters. No henri model is involved either way.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} adapter A henri store adapter
|
|
15
|
+
* @param {object} tables `{ endpoints }` table names
|
|
16
|
+
* @returns {object} A store backend
|
|
17
|
+
* @throws {WebhookError} When the adapter cannot hold the endpoints
|
|
18
|
+
*/
|
|
19
|
+
const storeFor = (adapter, tables) => {
|
|
20
|
+
if (!adapter) {
|
|
21
|
+
throw new WebhookError(
|
|
22
|
+
'HENRI_WEBHOOK_UNSUPPORTED_STORE',
|
|
23
|
+
'@usehenri/webhooks: no store to hold the endpoints'
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (adapter.mongoose) {
|
|
28
|
+
return mongo.create(adapter, tables);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (typeof adapter.query !== 'function') {
|
|
32
|
+
throw new WebhookError(
|
|
33
|
+
'HENRI_WEBHOOK_UNSUPPORTED_STORE',
|
|
34
|
+
`@usehenri/webhooks: the ${adapter.adapterName || 'unknown'} adapter has neither query() nor a MongoDB connection`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return sql.create(adapter, tables);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
module.exports = { mongo, sql, storeFor };
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
const debug = require('debug')('henri:webhooks:mongo');
|
|
2
|
+
|
|
3
|
+
const { WebhookError } = require('../errors');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The MongoDB backend of the endpoints collection.
|
|
7
|
+
*
|
|
8
|
+
* Documents carry the same field names as the SQL columns, on purpose: the
|
|
9
|
+
* two backends hand the package the same rows, so everything above this
|
|
10
|
+
* file is written once.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The MongoDB store
|
|
15
|
+
*
|
|
16
|
+
* @class MongoStore
|
|
17
|
+
*/
|
|
18
|
+
class MongoStore {
|
|
19
|
+
/**
|
|
20
|
+
* Creates an instance of MongoStore.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} adapter A henri mongoose (or disk) adapter
|
|
23
|
+
* @param {object} tables `{ endpoints }` collection names
|
|
24
|
+
* @memberof MongoStore
|
|
25
|
+
*/
|
|
26
|
+
constructor(adapter, tables) {
|
|
27
|
+
this.adapter = adapter;
|
|
28
|
+
this.tables = tables;
|
|
29
|
+
this.dialect = 'mongodb';
|
|
30
|
+
this.kind = 'mongo';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The database of the store
|
|
35
|
+
*
|
|
36
|
+
* @returns {object} A MongoDB database
|
|
37
|
+
* @throws {WebhookError} When the store is not connected
|
|
38
|
+
* @memberof MongoStore
|
|
39
|
+
*/
|
|
40
|
+
database() {
|
|
41
|
+
const connection =
|
|
42
|
+
this.adapter.mongoose && this.adapter.mongoose.connection;
|
|
43
|
+
|
|
44
|
+
if (!connection || connection.readyState !== 1 || !connection.db) {
|
|
45
|
+
throw new WebhookError(
|
|
46
|
+
'HENRI_WEBHOOK_UNSUPPORTED_STORE',
|
|
47
|
+
`@usehenri/webhooks: the ${this.adapter.name} store is not connected`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return connection.db;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The endpoints collection
|
|
56
|
+
*
|
|
57
|
+
* @returns {object} A MongoDB collection
|
|
58
|
+
* @memberof MongoStore
|
|
59
|
+
*/
|
|
60
|
+
endpoints() {
|
|
61
|
+
return this.database().collection(this.tables.endpoints);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Creates the collection and its indexes; idempotent
|
|
66
|
+
*
|
|
67
|
+
* @returns {Promise<Array<string>>} What was created
|
|
68
|
+
* @memberof MongoStore
|
|
69
|
+
*/
|
|
70
|
+
async install() {
|
|
71
|
+
const collection = this.endpoints();
|
|
72
|
+
|
|
73
|
+
await collection.createIndex({ id: 1 }, { unique: true });
|
|
74
|
+
await collection.createIndex({ disabled_at: 1, owner: 1 });
|
|
75
|
+
|
|
76
|
+
debug('indexes ready on %s', this.tables.endpoints);
|
|
77
|
+
|
|
78
|
+
return [`${this.tables.endpoints}: id (unique), owner + disabled_at`];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Drops the collection
|
|
83
|
+
*
|
|
84
|
+
* @returns {Promise<Array<string>>} What was dropped
|
|
85
|
+
* @memberof MongoStore
|
|
86
|
+
*/
|
|
87
|
+
async uninstall() {
|
|
88
|
+
await this.endpoints()
|
|
89
|
+
.drop()
|
|
90
|
+
.catch(() => null);
|
|
91
|
+
|
|
92
|
+
return [`${this.tables.endpoints} dropped`];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether the collection answers
|
|
97
|
+
*
|
|
98
|
+
* @returns {Promise<boolean>} true when it does
|
|
99
|
+
* @memberof MongoStore
|
|
100
|
+
*/
|
|
101
|
+
async installed() {
|
|
102
|
+
try {
|
|
103
|
+
await this.endpoints().countDocuments({}, { limit: 1 });
|
|
104
|
+
|
|
105
|
+
return true;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Inserts an endpoint
|
|
113
|
+
*
|
|
114
|
+
* @param {object} row A row, in database shape
|
|
115
|
+
* @returns {Promise<object>} The endpoint, read back
|
|
116
|
+
* @memberof MongoStore
|
|
117
|
+
*/
|
|
118
|
+
async insert(row) {
|
|
119
|
+
await this.endpoints().insertOne({ ...row });
|
|
120
|
+
|
|
121
|
+
return this.find(row.id);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* One endpoint by id
|
|
126
|
+
*
|
|
127
|
+
* @param {string} id The endpoint id
|
|
128
|
+
* @returns {Promise<?object>} The document, or null
|
|
129
|
+
* @memberof MongoStore
|
|
130
|
+
*/
|
|
131
|
+
async find(id) {
|
|
132
|
+
return this.endpoints().findOne({ id }, { projection: { _id: 0 } });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Writes a few fields of an endpoint
|
|
137
|
+
*
|
|
138
|
+
* @param {string} id The endpoint id
|
|
139
|
+
* @param {object} changes The fields to write
|
|
140
|
+
* @returns {Promise<?object>} The document, read back
|
|
141
|
+
* @memberof MongoStore
|
|
142
|
+
*/
|
|
143
|
+
async update(id, changes) {
|
|
144
|
+
await this.endpoints().updateOne({ id }, { $set: { ...changes } });
|
|
145
|
+
|
|
146
|
+
return this.find(id);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Deletes an endpoint
|
|
151
|
+
*
|
|
152
|
+
* @param {string} id The endpoint id
|
|
153
|
+
* @returns {Promise<boolean>} Whether there was one to delete
|
|
154
|
+
* @memberof MongoStore
|
|
155
|
+
*/
|
|
156
|
+
async remove(id) {
|
|
157
|
+
const answer = await this.endpoints().deleteOne({ id });
|
|
158
|
+
|
|
159
|
+
return (answer.deletedCount || 0) > 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The filter of a listing
|
|
164
|
+
*
|
|
165
|
+
* @param {object} filter `owner` and `disabled`
|
|
166
|
+
* @returns {object} A MongoDB filter
|
|
167
|
+
* @memberof MongoStore
|
|
168
|
+
*/
|
|
169
|
+
query(filter) {
|
|
170
|
+
const query = {};
|
|
171
|
+
|
|
172
|
+
if (typeof filter.owner === 'string') {
|
|
173
|
+
query.owner = filter.owner;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// `null` is a filter, not the absence of one
|
|
177
|
+
if (filter.owner === null) {
|
|
178
|
+
query.owner = null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (filter.disabled === false) {
|
|
182
|
+
query.disabled_at = null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (filter.disabled === true) {
|
|
186
|
+
query.disabled_at = { $ne: null };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return query;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The endpoints of an application, or of one owner
|
|
194
|
+
*
|
|
195
|
+
* @param {object} [filter={}] `owner`, `disabled`, `limit`, `offset`
|
|
196
|
+
* @returns {Promise<Array<object>>} The documents
|
|
197
|
+
* @memberof MongoStore
|
|
198
|
+
*/
|
|
199
|
+
async list(filter = {}) {
|
|
200
|
+
return this.endpoints()
|
|
201
|
+
.find(this.query(filter), { projection: { _id: 0 } })
|
|
202
|
+
.sort({ created_at: 1, id: 1 })
|
|
203
|
+
.skip(Math.max(0, Number(filter.offset) || 0))
|
|
204
|
+
.limit(Math.max(1, Math.min(Number(filter.limit) || 1000, 10000)))
|
|
205
|
+
.toArray();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* How many endpoints there are
|
|
210
|
+
*
|
|
211
|
+
* @param {object} [filter={}] `owner`, `disabled`
|
|
212
|
+
* @returns {Promise<number>} The count
|
|
213
|
+
* @memberof MongoStore
|
|
214
|
+
*/
|
|
215
|
+
async count(filter = {}) {
|
|
216
|
+
return this.endpoints().countDocuments(this.query(filter));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Builds the MongoDB store of an adapter
|
|
222
|
+
*
|
|
223
|
+
* @param {object} adapter A henri mongoose (or disk) adapter
|
|
224
|
+
* @param {object} tables `{ endpoints }` collection names
|
|
225
|
+
* @returns {MongoStore} The store
|
|
226
|
+
*/
|
|
227
|
+
const create = (adapter, tables) => new MongoStore(adapter, tables);
|
|
228
|
+
|
|
229
|
+
module.exports = { MongoStore, create };
|