wingbot-mongodb 2.18.0 → 2.19.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,364 @@
1
+ /**
2
+ * @author wingbot.ai
3
+ */
4
+ 'use strict';
5
+
6
+ const jsonwebtoken = require('jsonwebtoken');
7
+ const BaseStorage = require('./BaseStorage');
8
+
9
+ /** @typedef {import('mongodb/lib/db')} Db */
10
+
11
+ const LEVEL_CRITICAL = 'Critical';
12
+ const LEVEL_IMPORTANT = 'Important';
13
+ const LEVEL_DEBUG = 'Debug';
14
+
15
+ const TYPE_ERROR = 'Error';
16
+ const TYPE_WARN = 'Warn';
17
+ const TYPE_INFO = 'Info';
18
+
19
+ /**
20
+ * @typedef {object} TrackingEvent
21
+ * @prop {string} [type='audit']
22
+ * @prop {string} category
23
+ * @prop {string} action
24
+ * @prop {string} [label]
25
+ * @prop {object} [payload]
26
+ */
27
+
28
+ /**
29
+ * @typedef {object} User
30
+ * @prop {string} [id]
31
+ * @prop {string} [senderId]
32
+ * @prop {string} [pageId]
33
+ * @prop {string} [jwt] - jwt to check the authorship
34
+ */
35
+
36
+ /**
37
+ * @typedef {object} Meta
38
+ * @prop {string} [ip]
39
+ * @prop {string} [ua]
40
+ * @prop {string} [ro] - referrer || origin
41
+ */
42
+
43
+ /**
44
+ * @typedef {object} LogEntry
45
+ * @prop {string} date - ISO date
46
+ * @prop {number} delta - time skew in ms if there was a write conflict
47
+ * @prop {string} [eventType='audit']
48
+ * @prop {string} category
49
+ * @prop {string} action
50
+ * @prop {string} [label]
51
+ * @prop {object} [payload]
52
+ * @prop {string} level - (Critical|Important|Debug)
53
+ * @prop {boolean} ok - signature matches
54
+ * @prop {number} seq - sequence number
55
+ * @prop {string} type - (Error|Warn|Info)
56
+ * @prop {User} user
57
+ * @prop {string} wid - workspace id
58
+ * @prop {Meta} meta
59
+ */
60
+
61
+ /**
62
+ * JWT Verifier
63
+ *
64
+ * @callback JwtVerifier
65
+ * @param {string} token
66
+ * @param {string} userId
67
+ * @param {User} [user]
68
+ * @returns {Promise<boolean>}
69
+ */
70
+
71
+ /**
72
+ * @typedef {object} AuditLogEntry
73
+ * @prop {string} date - ISO date
74
+ * @prop {string} [eventType='audit']
75
+ * @prop {string} category
76
+ * @prop {string} action
77
+ * @prop {string} [label]
78
+ * @prop {object} [payload]
79
+ * @prop {string} level - (Critical|Important|Debug)
80
+ * @prop {string} type - (Error|Warn|Info)
81
+ * @prop {User} user
82
+ * @prop {string} wid - workspace id
83
+ * @prop {Meta} meta
84
+ */
85
+
86
+ /**
87
+ * Audit Log Callback
88
+ *
89
+ * @callback AuditLogCallback
90
+ * @param {AuditLogEntry} entry
91
+ * @returns {Promise}
92
+ */
93
+
94
+ /**
95
+ * Storage for audit logs with signatures chain
96
+ */
97
+ class AuditLogStorage extends BaseStorage {
98
+
99
+ /**
100
+ *
101
+ * @param {Db|{():Promise<Db>}} mongoDb
102
+ * @param {string} collectionName
103
+ * @param {{error:Function,log:Function}} [log] - console like logger
104
+ * @param {boolean} [isCosmo]
105
+ * @param {string|Promise<string>} [secret]
106
+ * @param {string|Promise<string>} [jwtVerifier]
107
+ */
108
+ constructor (mongoDb, collectionName = 'auditlog', log = console, isCosmo = false, secret = null, jwtVerifier = null) {
109
+ super(mongoDb, collectionName, log, isCosmo);
110
+
111
+ this.addIndex({
112
+ wid: 1,
113
+ seq: -1
114
+ }, {
115
+ unique: true,
116
+ name: 'wid_1_seq_-1'
117
+ });
118
+
119
+ if (isCosmo) {
120
+ this.addIndex({
121
+ wid: 1
122
+ }, {
123
+ name: 'wid_1'
124
+ });
125
+
126
+ this.addIndex({
127
+ seq: -1
128
+ }, {
129
+ name: 'seq_-1'
130
+ });
131
+ } else {
132
+ this.addIndex({
133
+ wid: 1, date: -1
134
+ }, {
135
+ name: 'wid_1_date_-1'
136
+ });
137
+ }
138
+
139
+ this.defaultWid = '0';
140
+
141
+ this.muteErrors = true;
142
+ this.maxRetries = 4;
143
+ this._secret = secret;
144
+
145
+ this.LEVEL_CRITICAL = LEVEL_CRITICAL;
146
+ this.LEVEL_IMPORTANT = LEVEL_IMPORTANT;
147
+ this.LEVEL_DEBUG = LEVEL_DEBUG;
148
+
149
+ this.TYPE_ERROR = TYPE_ERROR;
150
+ this.TYPE_WARN = TYPE_WARN;
151
+ this.TYPE_INFO = TYPE_INFO;
152
+
153
+ /**
154
+ * @type {JwtVerifier}
155
+ */
156
+ // @ts-ignore
157
+ this._jwtVerify = typeof jwtVerifier === 'function' || jwtVerifier === null
158
+ ? jwtVerifier
159
+ : async (token, userId) => {
160
+ const jwtSec = await Promise.resolve(jwtVerifier);
161
+ const decoded = await new Promise((resolve) => {
162
+ jsonwebtoken.verify(token, jwtSec, (err, res) => {
163
+ resolve(res || {});
164
+ });
165
+ });
166
+ return decoded.id === userId;
167
+ };
168
+
169
+ /** @type {AuditLogCallback} */
170
+ this.callback = null;
171
+ }
172
+
173
+ /**
174
+ * Add a log
175
+ *
176
+ * @param {TrackingEvent} event
177
+ * @param {User} user
178
+ * @param {Meta} [meta]
179
+ * @param {string} [wid] - workspace ID
180
+ * @param {string} [type]
181
+ * @param {string} [level]
182
+ * @param {Date} [date]
183
+ * @returns {Promise}
184
+ */
185
+ async log (
186
+ event,
187
+ user = {},
188
+ meta = {},
189
+ wid = this.defaultWid,
190
+ type = TYPE_INFO,
191
+ level = LEVEL_IMPORTANT,
192
+ date = new Date()
193
+ ) {
194
+ const {
195
+ type: eventType = 'audit',
196
+ ...rest
197
+ } = event;
198
+ const entry = {
199
+ date,
200
+ eventType,
201
+ ...rest,
202
+ level,
203
+ meta,
204
+ type,
205
+ user,
206
+ wid
207
+ };
208
+
209
+ const secret = await Promise.resolve(this._secret);
210
+ const stored = await this._storeWithRetry(secret, entry);
211
+ if (!this.callback) {
212
+ return;
213
+ }
214
+ try {
215
+ await this.callback(stored);
216
+ } catch (e) {
217
+ this._log.error('Failed to send AuditLog', e, entry);
218
+ }
219
+
220
+ // logEvent(level, type, workflowType, workflowInstance, eventData, account)
221
+ // level is the criticality of the event ('Critical','Important','Debug').
222
+ // type is the type of the event ('Error','Warn','Info').
223
+ /**
224
+ * - log (N/A)
225
+ - report (Debug)
226
+ - conversation (N/A)
227
+ - audit (Important)
228
+ - user (N/A)
229
+ */
230
+ }
231
+
232
+ /**
233
+ *
234
+ * @param {string} [wid] - workspace id
235
+ * @param {number} [fromSeq] - for paging
236
+ * @param {number} [limit]
237
+ * @returns {Promise<LogEntry[]>}
238
+ */
239
+ async list (wid = this.defaultWid, fromSeq = 0, limit = 40) {
240
+ const c = await this._getCollection();
241
+
242
+ const cond = { wid };
243
+
244
+ if (fromSeq) {
245
+ cond.seq = { $lt: fromSeq };
246
+ }
247
+
248
+ const data = await c.find({ wid })
249
+ .limit(limit + 1)
250
+ .sort({ seq: -1 })
251
+ .project({ _id: 0 })
252
+ .toArray();
253
+
254
+ const secret = await Promise.resolve(this._secret);
255
+
256
+ const len = data.length === limit + 1
257
+ ? data.length - 1
258
+ : data.length;
259
+ const ret = new Array(len);
260
+
261
+ let verifyUser = false;
262
+ for (let i = 0; i < len; i++) {
263
+ const {
264
+ sign,
265
+ ...log
266
+ } = data[i];
267
+
268
+ verifyUser = verifyUser || (log.user.id && log.user.jwt);
269
+
270
+ if (secret) {
271
+ const previous = data[i + 1] || { sign: null };
272
+ const objToSign = this._objectToSign(log);
273
+ const compare = this._signWithSecret(objToSign, secret, previous.sign);
274
+ log.ok = compare === sign;
275
+ if (!log.ok) {
276
+ this._log.error(`AuditLog: found wrong signature at wid: "${log.wid}", seq: "${log.seq}"`, log);
277
+ }
278
+ } else {
279
+ log.ok = null;
280
+ }
281
+
282
+ ret[i] = log;
283
+ }
284
+
285
+ if (verifyUser && this._jwtVerify) {
286
+ return Promise.all(
287
+ ret.map(async (log) => {
288
+ if (!log.user.id || !log.user.jwt) {
289
+ return log;
290
+ }
291
+ const userChecked = await this._jwtVerify(log.user.jwt, log.user.id, log.user);
292
+ return Object.assign(log, {
293
+ // it's ok, when there was null
294
+ ok: log.ok !== false && userChecked
295
+ });
296
+ })
297
+ );
298
+ }
299
+
300
+ return ret;
301
+ }
302
+
303
+ _wait (ms) {
304
+ return new Promise((r) => setTimeout(r, ms));
305
+ }
306
+
307
+ async _storeWithRetry (secret, entry, delta = 0, i = 1) {
308
+ const start = Date.now();
309
+ Object.assign(entry, { delta });
310
+ try {
311
+ await this._store(entry, secret);
312
+ return entry;
313
+ } catch (e) {
314
+ if (e.code === 11000) {
315
+ // duplicate key
316
+ if (i >= this.maxRetries) {
317
+ throw new Error('AuditLog: cannot store log due to max-retries');
318
+ } else {
319
+ await this._wait((i * 50) + (Math.random() * 100));
320
+ const add = Date.now() - start;
321
+ return this._storeWithRetry(secret, entry, delta + add, i + 1);
322
+ }
323
+ } else if (this.muteErrors) {
324
+ this._log.error('Audit log store error', e, entry);
325
+ return entry;
326
+ } else {
327
+ throw e;
328
+ }
329
+ }
330
+ }
331
+
332
+ async _store (entry, secret) {
333
+ const c = await this._getCollection();
334
+
335
+ const previous = await c.findOne({
336
+ wid: entry.wid
337
+ }, {
338
+ sort: { seq: -1 },
339
+ projection: { seq: 1, _id: 0, sign: 1 }
340
+ });
341
+
342
+ Object.assign(entry, {
343
+ seq: previous ? previous.seq + 1 : 0
344
+ });
345
+
346
+ let insert;
347
+ if (secret) {
348
+ insert = this._objectToSign(entry);
349
+ const sign = this._signWithSecret(insert, secret, previous ? previous.sign : null);
350
+ Object.assign(insert, { sign });
351
+ } else {
352
+ insert = {
353
+ ...entry,
354
+ sign: null
355
+ };
356
+ }
357
+
358
+ // @ts-ignore
359
+ await c.insertOne(insert);
360
+ }
361
+
362
+ }
363
+
364
+ module.exports = AuditLogStorage;
@@ -4,15 +4,19 @@
4
4
  'use strict';
5
5
 
6
6
  const mongodb = require('mongodb'); // eslint-disable-line no-unused-vars
7
+ const crypto = require('crypto');
8
+
9
+ /** @typedef {import('mongodb/lib/db')} Db */
10
+ /** @typedef {import('mongodb/lib/collection')} Collection */
7
11
 
8
12
  class BaseStorage {
9
13
 
10
14
  /**
11
15
  *
12
- * @param {mongodb.Db|{():Promise<mongodb.Db>}} mongoDb
16
+ * @param {Db|{():Promise<Db>}} mongoDb
13
17
  * @param {string} collectionName
14
18
  * @param {{error:Function,log:Function}} [log] - console like logger
15
- * @param {boolean} isCosmo
19
+ * @param {boolean} [isCosmo]
16
20
  * @example
17
21
  *
18
22
  * const { BaseStorage } = require('winbot-mongodb');
@@ -45,11 +49,14 @@ class BaseStorage {
45
49
  this._log = log;
46
50
 
47
51
  /**
48
- * @type {Promise<mongodb.Collection>}
52
+ * @type {Collection|Promise<Collection>}
49
53
  */
50
54
  this._collection = null;
51
55
 
52
56
  this._indexes = [];
57
+
58
+ this.ignoredSignatureKeys = ['_id', 'sign'];
59
+ this._secret = null;
53
60
  }
54
61
 
55
62
  /**
@@ -101,7 +108,7 @@ class BaseStorage {
101
108
  /**
102
109
  * Returns the collection to operate with
103
110
  *
104
- * @returns {Promise<mongodb.Collection>}
111
+ * @returns {Promise<Collection>}
105
112
  */
106
113
  async _getCollection () {
107
114
  if (this._collection === null) {
@@ -152,6 +159,44 @@ class BaseStorage {
152
159
  }, Promise.resolve());
153
160
  }
154
161
 
162
+ async _sign (object) {
163
+ if (!this._secret) {
164
+ return object;
165
+ }
166
+ const secret = await Promise.resolve(this._secret);
167
+ const objToSign = this._objectToSign(object);
168
+ const sign = this._signWithSecret(objToSign, secret);
169
+
170
+ return Object.assign(objToSign, {
171
+ sign
172
+ });
173
+ }
174
+
175
+ _objectToSign (object) {
176
+ const entries = Object.keys(object)
177
+ .filter((key) => !this.ignoredSignatureKeys.includes(key));
178
+
179
+ entries.sort();
180
+
181
+ return entries.reduce((o, key) => {
182
+ let val = object[key];
183
+ if (val instanceof Date) {
184
+ val = val.toISOString();
185
+ }
186
+ return Object.assign(o, { [key]: val });
187
+ }, {});
188
+ }
189
+
190
+ _signWithSecret (objToSign, secret, previous = null) {
191
+ const h = crypto.createHmac('sha3-224', secret)
192
+ .update(JSON.stringify(objToSign));
193
+
194
+ if (previous) {
195
+ h.update(previous);
196
+ }
197
+
198
+ return h.digest('base64');
199
+ }
155
200
  }
156
201
 
157
202
  module.exports = BaseStorage;
@@ -131,7 +131,7 @@ class BotTokenStorage {
131
131
  }
132
132
  }, {
133
133
  upsert: true,
134
- returnOriginal: false
134
+ returnDocument: 'after'
135
135
  });
136
136
 
137
137
  res = res.value;
@@ -3,12 +3,13 @@
3
3
  */
4
4
  'use strict';
5
5
 
6
- const mongodb = require('mongodb'); // eslint-disable-line no-unused-vars
7
6
  const BaseStorage = require('./BaseStorage');
8
7
 
9
8
  const PAGE_SENDER_TIMESTAMP = 'pageId_1_senderId_1_timestamp_-1';
10
9
  const TIMESTAMP = 'timestamp_1';
11
10
 
11
+ /** @typedef {import('mongodb/lib/db')} Db */
12
+
12
13
  /**
13
14
  * Storage for conversation logs
14
15
  *
@@ -18,12 +19,13 @@ class ChatLogStorage extends BaseStorage {
18
19
 
19
20
  /**
20
21
  *
21
- * @param {mongodb.Db|{():Promise<mongodb.Db>}} mongoDb
22
+ * @param {Db|{():Promise<Db>}} mongoDb
22
23
  * @param {string} collectionName
23
24
  * @param {{error:Function,log:Function}} [log] - console like logger
24
- * @param {boolean} isCosmo
25
+ * @param {boolean} [isCosmo]
26
+ * @param {string|Promise<string>} [secret]
25
27
  */
26
- constructor (mongoDb, collectionName = 'chatlogs', log = console, isCosmo = false) {
28
+ constructor (mongoDb, collectionName = 'chatlogs', log = console, isCosmo = false, secret = null) {
27
29
  super(mongoDb, collectionName, log, isCosmo);
28
30
 
29
31
  this.addIndex({
@@ -43,6 +45,7 @@ class ChatLogStorage extends BaseStorage {
43
45
  }
44
46
 
45
47
  this.muteErrors = true;
48
+ this._secret = secret;
46
49
  }
47
50
 
48
51
  /**
@@ -54,6 +57,7 @@ class ChatLogStorage extends BaseStorage {
54
57
  * @param {number} [limit]
55
58
  * @param {number} [endAt] - iterate backwards to history
56
59
  * @param {number} [startAt] - iterate forward to last interaction
60
+ * @returns {Promise<object[]>}
57
61
  */
58
62
  async getInteractions (senderId, pageId, limit = 10, endAt = null, startAt = null) {
59
63
  const c = await this._getCollection();
@@ -80,14 +84,33 @@ class ChatLogStorage extends BaseStorage {
80
84
  const res = await c.find(q)
81
85
  .limit(limit)
82
86
  .sort({ timestamp: orderBackwards ? 1 : -1 })
83
- .project({ _id: 0, time: 0 })
87
+ .project({ _id: 0 })
84
88
  .toArray();
85
89
 
86
90
  if (!orderBackwards) {
87
91
  res.reverse();
88
92
  }
89
93
 
90
- return res;
94
+ if (!this._secret) {
95
+ return res.map((r) => Object.assign(r, { ok: null }));
96
+ }
97
+
98
+ const secret = await Promise.resolve(this._secret);
99
+
100
+ return res.map((r) => {
101
+ const {
102
+ sign,
103
+ ...log
104
+ } = r;
105
+ const objToSign = this._objectToSign(log);
106
+ const compare = this._signWithSecret(objToSign, secret);
107
+ const ok = compare === sign;
108
+ if (!ok) {
109
+ this._log.error(`ChatLog: found wrong signature at pageId: "${r.pageId}", senderId: "${r.senderId}", at: ${r.timestamp}`, r);
110
+ }
111
+
112
+ return Object.assign(log, { ok });
113
+ });
91
114
  }
92
115
 
93
116
  /**
@@ -102,22 +125,38 @@ class ChatLogStorage extends BaseStorage {
102
125
  log (senderId, responses = [], request = {}, metadata = {}) {
103
126
  const log = {
104
127
  senderId,
105
- time: new Date(request.timestamp || Date.now()),
106
128
  request,
107
- responses
129
+ responses,
130
+ ...metadata
108
131
  };
109
132
 
110
- Object.assign(log, metadata);
111
-
112
- return this._getCollection()
113
- .then((c) => c.insertOne(log))
114
- .catch((err) => {
115
- this._log.error('Failed to store chat log', err, log);
133
+ return this._storeLog(log);
134
+ }
116
135
 
117
- if (!this.muteErrors) {
118
- throw err;
119
- }
136
+ async _storeLog (event) {
137
+ let log = event;
138
+ if (!event.timestamp) {
139
+ Object.assign(event, {
140
+ timestamp: event.request.timestamp || Date.now()
141
+ });
142
+ }
143
+ if (typeof event.pageId === 'undefined') {
144
+ Object.assign(event, {
145
+ pageId: null
120
146
  });
147
+ }
148
+ try {
149
+ const c = await this._getCollection();
150
+ log = await this._sign(log);
151
+ // @ts-ignore
152
+ await c.insertOne(log);
153
+ } catch (e) {
154
+ this._log.error('Failed to store chat log', e, log);
155
+
156
+ if (!this.muteErrors) {
157
+ throw e;
158
+ }
159
+ }
121
160
  }
122
161
 
123
162
  /**
@@ -135,23 +174,15 @@ class ChatLogStorage extends BaseStorage {
135
174
  error (err, senderId, responses = [], request = {}, metadata = {}) {
136
175
  const log = {
137
176
  senderId,
138
- time: new Date(request.timestamp || Date.now()),
139
177
  request,
140
178
  responses,
141
- err: `${err}`
179
+ err: `${err}`,
180
+ ...metadata
142
181
  };
143
182
 
144
183
  Object.assign(log, metadata);
145
184
 
146
- return this._getCollection()
147
- .then((c) => c.insertOne(log))
148
- .catch((storeError) => {
149
- this._log.error('Failed to store chat log', storeError, log);
150
-
151
- if (!this.muteErrors) {
152
- throw storeError;
153
- }
154
- });
185
+ return this._storeLog(log);
155
186
  }
156
187
 
157
188
  }
@@ -381,7 +381,7 @@ class NotificationsStorage {
381
381
  }
382
382
  }, {
383
383
  sort: { enqueue: 1 },
384
- returnOriginal: false
384
+ returnDocument: 'after'
385
385
  });
386
386
  if (found.value) {
387
387
  pop.push(this._mapGenericObject(found.value));
@@ -475,7 +475,7 @@ class NotificationsStorage {
475
475
  }, {
476
476
  $set: data
477
477
  }, {
478
- returnOriginal: false
478
+ returnDocument: 'after'
479
479
  });
480
480
 
481
481
  return this._mapGenericObject(res.value);
@@ -565,7 +565,7 @@ class NotificationsStorage {
565
565
  [eventType]: ts
566
566
  }
567
567
  }, {
568
- returnOriginal: false
568
+ returnDocument: 'after'
569
569
  }))
570
570
  );
571
571
 
@@ -602,7 +602,7 @@ class NotificationsStorage {
602
602
  id: campaign.id
603
603
  }, update, {
604
604
  upsert: true,
605
- returnOriginal: false
605
+ returnDocument: 'after'
606
606
  });
607
607
  ret = this._mapCampaign(res.value);
608
608
  } else {
@@ -661,7 +661,7 @@ class NotificationsStorage {
661
661
  }, {
662
662
  $set: data
663
663
  }, {
664
- returnOriginal: false
664
+ returnDocument: 'after'
665
665
  });
666
666
 
667
667
  return this._mapCampaign(res.value);
@@ -681,7 +681,7 @@ class NotificationsStorage {
681
681
  }, {
682
682
  $set: { startAt: null }
683
683
  }, {
684
- returnOriginal: true
684
+ returnDocument: 'before'
685
685
  });
686
686
 
687
687
  return this._mapCampaign(res.value);
@@ -807,7 +807,7 @@ class NotificationsStorage {
807
807
  }, {
808
808
  $pull: { subs: tag }
809
809
  }, {
810
- returnOriginal: false
810
+ returnDocument: 'after'
811
811
  });
812
812
 
813
813
  if (res.value) {
@@ -115,7 +115,7 @@ class StateStorage extends BaseStorage {
115
115
  $set
116
116
  }, {
117
117
  upsert: true,
118
- returnOriginal: false,
118
+ returnDocument: 'after',
119
119
  projection: {
120
120
  _id: 0
121
121
  }
package/src/main.js CHANGED
@@ -9,6 +9,7 @@ const BotTokenStorage = require('./BotTokenStorage');
9
9
  const ChatLogStorage = require('./ChatLogStorage');
10
10
  const BotConfigStorage = require('./BotConfigStorage');
11
11
  const AttachmentCache = require('./AttachmentCache');
12
+ const AuditLogStorage = require('./AuditLogStorage');
12
13
  const NotificationsStorage = require('./NotificationsStorage');
13
14
 
14
15
  module.exports = {
@@ -18,5 +19,6 @@ module.exports = {
18
19
  ChatLogStorage,
19
20
  BotConfigStorage,
20
21
  AttachmentCache,
21
- NotificationsStorage
22
+ NotificationsStorage,
23
+ AuditLogStorage
22
24
  };