@jsreport/jsreport-core 3.12.0 → 4.0.1

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.
@@ -47,40 +47,46 @@ Settings.prototype.addOrSet = async function (key, avalue, req) {
47
47
  }
48
48
  }
49
49
 
50
- Settings.prototype.init = async function (documentStore, authorization) {
50
+ Settings.prototype.init = async function (documentStore, { authentication, authorization }) {
51
51
  this.documentStore = documentStore
52
52
 
53
- if (authorization != null) {
53
+ if (authentication != null && authorization != null) {
54
54
  const col = documentStore.collection('settings')
55
55
 
56
56
  // settings can be read by anyone so we don't add find listeners,
57
57
  // we only care about modification listeners
58
- col.beforeInsertListeners.add('settings', (doc, req) => {
58
+ col.beforeInsertListeners.add('settings', async (doc, req) => {
59
59
  if (req && req.context && req.context.skipAuthorization) {
60
60
  return
61
61
  }
62
62
 
63
- if (req && req.context && req.context.user && !req.context.user.isAdmin) {
63
+ const isAdmin = await authentication.isUserAdmin(req?.context?.user, req)
64
+
65
+ if (req && req.context && req.context.user && !isAdmin) {
64
66
  throw authorization.createAuthorizationError(col.name)
65
67
  }
66
68
  })
67
69
 
68
- col.beforeUpdateListeners.add('settings', (q, u, options, req) => {
70
+ col.beforeUpdateListeners.add('settings', async (q, u, options, req) => {
69
71
  if (req && req.context && req.context.skipAuthorization) {
70
72
  return
71
73
  }
72
74
 
73
- if (req && req.context && req.context.user && !req.context.user.isAdmin) {
75
+ const isAdmin = await authentication.isUserAdmin(req?.context?.user, req)
76
+
77
+ if (req && req.context && req.context.user && !isAdmin) {
74
78
  throw authorization.createAuthorizationError(col.name)
75
79
  }
76
80
  })
77
81
 
78
- col.beforeRemoveListeners.add('settings', (q, req) => {
82
+ col.beforeRemoveListeners.add('settings', async (q, req) => {
79
83
  if (req && req.context && req.context.skipAuthorization) {
80
84
  return
81
85
  }
82
86
 
83
- if (req && req.context && req.context.user && !req.context.user.isAdmin) {
87
+ const isAdmin = await authentication.isUserAdmin(req?.context?.user, req)
88
+
89
+ if (req && req.context && req.context.user && !isAdmin) {
84
90
  throw authorization.createAuthorizationError(col.name)
85
91
  }
86
92
  })
@@ -1,99 +1,104 @@
1
- /*!
2
- * Copyright(c) 2018 Jan Blaha
3
- *
4
- * DocumentStore data layer provider using just memory.
5
- */
6
-
7
- const extend = require('node.extend.without.arrays')
8
- const { nanoid } = require('nanoid')
9
- const omit = require('lodash.omit')
10
- const mingo = require('@jsreport/mingo')
11
- const Transaction = require('./transaction')
12
- const Queue = require('./queue')
13
-
14
- module.exports = () => {
15
- return {
16
- load (model) {
17
- this.model = model
18
- this.transaction = Transaction({ queue: Queue() })
19
-
20
- return this.transaction.operation(async (documents) => {
21
- Object.keys(model.entitySets).forEach((e) => (documents[e] = []))
22
- })
23
- },
24
-
25
- beginTransaction () {
26
- return this.transaction.begin()
27
- },
28
-
29
- async commitTransaction (tran) {
30
- await this.transaction.commit(tran)
31
- },
32
-
33
- async rollbackTransaction (tran) {
34
- return this.transaction.rollback(tran)
35
- },
36
-
37
- find (entitySet, query, fields, opts = {}) {
38
- const documents = this.transaction.getCurrentDocuments(opts)
39
- const cursor = mingo.find(documents[entitySet], query, fields)
40
-
41
- // the queue is not used here because reads are supposed to not block
42
- cursor.toArray = () => cursor.all().map((e) => extend(true, {}, omit(e, '$$etag')))
43
-
44
- return cursor
45
- },
46
-
47
- insert (entitySet, doc, opts = {}) {
48
- return this.transaction.operation(opts, async (documents) => {
49
- doc._id = doc._id || nanoid(16)
50
- const newDoc = extend(true, {}, doc)
51
- newDoc.$$etag = Date.now()
52
- documents[entitySet].push(newDoc)
53
- return doc
54
- })
55
- },
56
-
57
- async update (entitySet, q, u, opts = {}) {
58
- let count
59
-
60
- const res = await this.transaction.operation(opts, async (documents) => {
61
- const toUpdate = mingo.find(documents[entitySet], q).all()
62
-
63
- count = toUpdate.length
64
-
65
- // need to get of queue first before calling insert, otherwise we get a deathlock
66
- if (toUpdate.length === 0 && opts.upsert) {
67
- return 'insert'
68
- }
69
-
70
- for (const doc of toUpdate) {
71
- Object.assign(doc, u.$set || {})
72
- doc.$$etag = Date.now()
73
- }
74
- })
75
-
76
- if (res === 'insert') {
77
- await this.insert(entitySet, u.$set, opts)
78
- return 1
79
- }
80
-
81
- return count
82
- },
83
-
84
- remove (entitySet, q, opts = {}) {
85
- return this.transaction.operation(opts, async (documents) => {
86
- const toRemove = mingo.find(documents[entitySet], q).all()
87
- documents[entitySet] = documents[entitySet].filter(d => !toRemove.includes(d))
88
- })
89
- },
90
-
91
- drop (opts = {}) {
92
- return this.transaction.operation(opts, async (documents) => {
93
- for (const [entitySetName] of Object.entries(documents)) {
94
- documents[entitySetName] = []
95
- }
96
- })
97
- }
98
- }
99
- }
1
+ /*!
2
+ * Copyright(c) 2018 Jan Blaha
3
+ *
4
+ * DocumentStore data layer provider using just memory.
5
+ */
6
+
7
+ const extend = require('node.extend.without.arrays')
8
+ const { nanoid } = require('nanoid')
9
+ const omit = require('lodash.omit')
10
+ const mingo = require('@jsreport/mingo')
11
+ const Transaction = require('./transaction')
12
+ const Queue = require('./queue')
13
+
14
+ module.exports = () => {
15
+ return {
16
+ load (model) {
17
+ this.model = model
18
+ this.transaction = Transaction({ queue: Queue() })
19
+
20
+ return this.transaction.operation(async (documents) => {
21
+ Object.keys(model.entitySets).forEach((e) => (documents[e] = []))
22
+ })
23
+ },
24
+
25
+ beginTransaction () {
26
+ return this.transaction.begin()
27
+ },
28
+
29
+ async commitTransaction (tran) {
30
+ await this.transaction.commit(tran)
31
+ },
32
+
33
+ async rollbackTransaction (tran) {
34
+ return this.transaction.rollback(tran)
35
+ },
36
+
37
+ find (entitySet, query, fields, opts = {}) {
38
+ const documents = this.transaction.getCurrentDocuments(opts)
39
+ const cursor = mingo.find(documents[entitySet], query, fields)
40
+
41
+ // the queue is not used here because reads are supposed to not block
42
+ cursor.toArray = () => cursor.all().map((e) => extend(true, {}, omit(e, '$$etag')))
43
+
44
+ return cursor
45
+ },
46
+
47
+ insert (entitySet, doc, opts = {}) {
48
+ doc._id = doc._id || nanoid(16)
49
+ const clonnedDoc = extend(true, {}, doc)
50
+ clonnedDoc.$$etag = Date.now()
51
+
52
+ return this.transaction.operation(opts, async (documents) => {
53
+ documents[entitySet].push(clonnedDoc)
54
+ return doc
55
+ })
56
+ },
57
+
58
+ async update (entitySet, q, u, opts = {}) {
59
+ let count
60
+ const qClone = extend(true, {}, q)
61
+ const setClone = extend(true, {}, u.$set)
62
+
63
+ const res = await this.transaction.operation(opts, async (documents) => {
64
+ const toUpdate = mingo.find(documents[entitySet], qClone).all()
65
+
66
+ count = toUpdate.length
67
+
68
+ // need to get of queue first before calling insert, otherwise we get a deathlock
69
+ if (toUpdate.length === 0 && opts.upsert) {
70
+ return 'insert'
71
+ }
72
+
73
+ for (const doc of toUpdate) {
74
+ Object.assign(doc, setClone)
75
+ doc.$$etag = Date.now()
76
+ }
77
+ })
78
+
79
+ if (res === 'insert') {
80
+ await this.insert(entitySet, setClone, opts)
81
+ return 1
82
+ }
83
+
84
+ return count
85
+ },
86
+
87
+ remove (entitySet, q, opts = {}) {
88
+ const qClone = extend(true, {}, q)
89
+
90
+ return this.transaction.operation(opts, async (documents) => {
91
+ const toRemove = mingo.find(documents[entitySet], qClone).all()
92
+ documents[entitySet] = documents[entitySet].filter(d => !toRemove.includes(d))
93
+ })
94
+ },
95
+
96
+ drop (opts = {}) {
97
+ return this.transaction.operation(opts, async (documents) => {
98
+ for (const [entitySetName] of Object.entries(documents)) {
99
+ documents[entitySetName] = []
100
+ }
101
+ })
102
+ }
103
+ }
104
+ }
@@ -140,6 +140,10 @@ module.exports = (reporter) => {
140
140
  const executionFnParsedParamsKey = `entity:${entity.shortid || 'anonymous'}:helpers:${normalizedHelpers}`
141
141
 
142
142
  const initFn = async (getTopLevelFunctions, compileScript) => {
143
+ if (reporter.options.trustUserCode === false) {
144
+ return null
145
+ }
146
+
143
147
  if (systemHelpersCache != null) {
144
148
  return systemHelpersCache
145
149
  }
@@ -259,12 +263,30 @@ module.exports = (reporter) => {
259
263
  templatesCache.reset()
260
264
  }
261
265
 
266
+ let helpersStr = normalizedHelpers
267
+ if (reporter.options.trustUserCode === false) {
268
+ const registerResults = await reporter.registerHelpersListeners.fire()
269
+ const systemHelpers = []
270
+
271
+ for (const result of registerResults) {
272
+ if (result == null) {
273
+ continue
274
+ }
275
+
276
+ if (typeof result === 'string') {
277
+ systemHelpers.push(result)
278
+ }
279
+ }
280
+ const systemHelpersStr = systemHelpers.join('\n')
281
+ helpersStr = normalizedHelpers + '\n' + systemHelpersStr
282
+ }
283
+
262
284
  try {
263
285
  return await reporter.runInSandbox({
264
286
  context: {
265
287
  ...(engine.createContext ? engine.createContext(req) : {})
266
288
  },
267
- userCode: normalizedHelpers,
289
+ userCode: helpersStr,
268
290
  initFn,
269
291
  executionFn,
270
292
  currentPath: entityPath,
@@ -19,6 +19,7 @@ class WorkerReporter extends Reporter {
19
19
 
20
20
  this._executeMain = executeMain
21
21
  this._initialized = false
22
+ this._lockedDown = false
22
23
  this._documentStoreData = documentStore
23
24
  this._requestContextMetaConfigCollection = new Map()
24
25
  this._proxyRegistrationFns = []
@@ -80,6 +81,50 @@ class WorkerReporter extends Reporter {
80
81
 
81
82
  await this.initializeListeners.fire()
82
83
 
84
+ if (!this._lockedDown && this.options.trustUserCode === false) {
85
+ require('@jsreport/ses')
86
+
87
+ // eslint-disable-next-line
88
+ lockdown({
89
+ // don't change locale based methods which users may be using in their templates
90
+ localeTaming: 'unsafe',
91
+ errorTaming: 'unsafe',
92
+ stackFiltering: 'verbose',
93
+ /*
94
+ FROM SES DOCS
95
+ The 'severe' setting enables all the properties on at least Object.prototype, which is sometimes needed for compatibility with code generated by rollup or webpack.
96
+ However, this extra compatibility comes at the price of a miserable debugging experience.
97
+
98
+ We need this to make jsrender working, which overrides constructor.
99
+ In case we need to put back default, we will need to fork jsrender and change the following line
100
+ (Tag.prototype = compiledDef).constructor = compiledDef._ctr = Tag;
101
+ x
102
+ Tag.prototype = compiledDef
103
+ compiledDef._ctr = Tag
104
+ */
105
+ overrideTaming: 'severe'
106
+ })
107
+
108
+ // in this mode we alias the unsafe methods to safe ones
109
+ Buffer.allocUnsafe = function allocUnsafe (size) {
110
+ return Buffer.alloc(size)
111
+ }
112
+
113
+ Buffer.allocUnsafeSlow = function allocUnsafeSlow (size) {
114
+ return Buffer.alloc(size)
115
+ }
116
+
117
+ // we also harden Buffer because we expose it to sandbox
118
+ // eslint-disable-next-line
119
+ harden(Buffer)
120
+
121
+ // we need to expose Intl to sandbox
122
+ // eslint-disable-next-line
123
+ harden(Intl)
124
+
125
+ this._lockedDown = true
126
+ }
127
+
83
128
  this._initialized = true
84
129
  }
85
130
 
@@ -1,12 +1,11 @@
1
1
  const util = require('util')
2
- const { VM, VMScript } = require('vm2')
3
- const originalVM = require('vm')
2
+ const vm = require('vm')
4
3
  const stackTrace = require('stack-trace')
5
4
  const { codeFrameColumns } = require('@babel/code-frame')
6
5
  const createPropertiesManager = require('./propertiesSandbox')
7
6
  const createSandboxRequire = require('./requireSandbox')
8
7
 
9
- module.exports = function createSandbox (_sandbox, options = {}) {
8
+ module.exports = async function createSandbox (_sandbox, options = {}) {
10
9
  const {
11
10
  rootDirectory,
12
11
  onLog,
@@ -47,11 +46,35 @@ module.exports = function createSandbox (_sandbox, options = {}) {
47
46
 
48
47
  propsManager.applyPropertiesConfigTo(sandbox)
49
48
 
50
- let safeVM
51
- // with standard vm this variable is the same as context, with vm2 it is a proxy of context
52
- // (which is not the real internal context)
49
+ const sourceFilesInfo = new Map()
50
+ // eslint-disable-next-line
51
+ let compartment
52
+
53
+ if (safeExecution) {
54
+ // eslint-disable-next-line
55
+ compartment = new Compartment()
56
+ }
57
+
53
58
  let vmSandbox
54
59
 
60
+ if (safeExecution) {
61
+ vmSandbox = compartment.globalThis
62
+
63
+ vmSandbox = Object.assign(vmSandbox, {
64
+ // SES does not expose the Buffer, Intl by default, we expose it because it is handy for users,
65
+ // it is exposed as it is, because we already harden() it on reporter init
66
+ Buffer,
67
+ Intl,
68
+ // we need to expose Date, and Math to allow Date.now(), Math.random()
69
+ // these objects are already hardened by lockdown()
70
+ Date,
71
+ Math
72
+ })
73
+ } else {
74
+ vmSandbox = vm.createContext(undefined)
75
+ vmSandbox.Buffer = Buffer
76
+ }
77
+
55
78
  const doSandboxRequire = createSandboxRequire(safeExecution, isolateModules, modulesCache, {
56
79
  rootDirectory,
57
80
  requirePaths,
@@ -63,43 +86,17 @@ module.exports = function createSandbox (_sandbox, options = {}) {
63
86
 
64
87
  Object.assign(sandbox, {
65
88
  console: _console,
66
- require (m) { return doSandboxRequire(m, { context: vmSandbox }) }
67
- })
68
-
69
- if (safeExecution) {
70
- safeVM = new VM()
71
-
72
- // delete the vm.sandbox.global because it introduces json stringify issues
73
- // and we don't need such global in context
74
- delete safeVM.sandbox.global
75
-
76
- for (const name in sandbox) {
77
- safeVM.setGlobal(name, sandbox[name])
89
+ require: (m) => { return doSandboxRequire(m, { context: vmSandbox }) },
90
+ setTimeout: (...args) => {
91
+ return setTimeout(...args)
92
+ },
93
+ clearTimeout: (...args) => {
94
+ return clearTimeout(...args)
78
95
  }
96
+ })
79
97
 
80
- // so far we don't have the need to have access to real vm context inside vm2,
81
- // but if we need it, we should use the code bellow to get it.
82
- // NOTE: if we need to upgrade vm2 we will need to check the source of this function
83
- // in vm2 repo and see if we need to change this,
84
- // we just execute this to get access to the internal context, so we can use it later
85
- // with the our require function, in newer versions of vm2 we may need to change how to
86
- // get access to it
87
- // https://github.com/patriksimek/vm2/blob/3.9.17/lib/vm.js#L281
88
- // safeVM._runScript({
89
- // runInContext: (_context) => {
90
- // vmContext = _context
91
- // return ''
92
- // }
93
- // })
94
-
95
- vmSandbox = safeVM.sandbox
96
- } else {
97
- vmSandbox = originalVM.createContext(undefined)
98
- vmSandbox.Buffer = Buffer
99
-
100
- for (const name in sandbox) {
101
- vmSandbox[name] = sandbox[name]
102
- }
98
+ for (const name in sandbox) {
99
+ vmSandbox[name] = sandbox[name]
103
100
  }
104
101
 
105
102
  // processing top level props here because getter/setter descriptors
@@ -112,8 +109,6 @@ module.exports = function createSandbox (_sandbox, options = {}) {
112
109
  vmSandbox[info.globalVariableName] = doSandboxRequire(info.module, { context: vmSandbox, useMap: false, allowAllModules: true })
113
110
  }
114
111
 
115
- const sourceFilesInfo = new Map()
116
-
117
112
  return {
118
113
  sandbox: vmSandbox,
119
114
  console: _console,
@@ -128,29 +123,20 @@ module.exports = function createSandbox (_sandbox, options = {}) {
128
123
  return doSandboxRequire(modulePath, { context: vmSandbox, allowAllModules: true })
129
124
  },
130
125
  async run (codeOrScript, { filename, errorLineNumberOffset = 0, source, entity, entitySet } = {}) {
131
- let runScript
132
-
133
126
  if (filename != null && source != null) {
134
127
  sourceFilesInfo.set(filename, { filename, source, entity, entitySet, errorLineNumberOffset })
135
128
  }
136
129
 
137
- const script = typeof codeOrScript !== 'string' ? codeOrScript : doCompileScript(codeOrScript, filename, safeExecution)
138
-
139
- if (safeExecution) {
140
- runScript = async function runScript () {
141
- return safeVM.run(script)
142
- }
143
- } else {
144
- runScript = async function runScript () {
145
- return script.runInContext(vmSandbox, {
146
- displayErrors: true
147
- })
130
+ try {
131
+ if (safeExecution) {
132
+ return await compartment.evaluate(codeOrScript + `\n//# sourceURL=${filename}`)
148
133
  }
149
- }
150
134
 
151
- try {
152
- const result = await runScript()
153
- return result
135
+ const script = typeof codeOrScript !== 'string' ? codeOrScript : doCompileScript(codeOrScript, filename, safeExecution)
136
+
137
+ return await script.runInContext(vmSandbox, {
138
+ displayErrors: true
139
+ })
154
140
  } catch (e) {
155
141
  decorateErrorMessage(e, sourceFilesInfo)
156
142
 
@@ -161,47 +147,19 @@ module.exports = function createSandbox (_sandbox, options = {}) {
161
147
  }
162
148
 
163
149
  function doCompileScript (code, filename, safeExecution) {
164
- let script
165
-
166
150
  if (safeExecution) {
167
- script = new VMScript(code, filename)
168
-
169
- // NOTE: if we need to upgrade vm2 we will need to check the source of this function
170
- // in vm2 repo and see if we need to change this,
171
- // we needed to override this method because we want "displayErrors" to be true in order
172
- // to show nice error when the compile of a script fails
173
- // https://github.com/patriksimek/vm2/blob/3.9.17/lib/script.js#L329
174
- script._compile = function (prefix, suffix) {
175
- return new originalVM.Script(prefix + this.getCompiledCode() + suffix, {
176
- __proto__: null,
177
- filename: this.filename,
178
- displayErrors: true,
179
- lineOffset: this.lineOffset,
180
- columnOffset: this.columnOffset,
181
- // THIS FN WAS TAKEN FROM vm2 source, nothing special here
182
- importModuleDynamically: () => {
183
- // We can't throw an error object here because since vm.Script doesn't store a context, we can't properly contextify that error object.
184
- // eslint-disable-next-line no-throw-literal
185
- throw 'Dynamic imports are not allowed.'
186
- }
187
- })
188
- }
189
-
190
- // do the compilation
191
- script._compileVM()
192
- } else {
193
- script = new originalVM.Script(code, {
194
- filename,
195
- displayErrors: true,
196
- importModuleDynamically: () => {
197
- // We can't throw an error object here because since vm.Script doesn't store a context, we can't properly contextify that error object.
198
- // eslint-disable-next-line no-throw-literal
199
- throw 'Dynamic imports are not allowed.'
200
- }
201
- })
151
+ return code
202
152
  }
203
153
 
204
- return script
154
+ return new vm.Script(code, {
155
+ filename,
156
+ displayErrors: true,
157
+ importModuleDynamically: () => {
158
+ // We can't throw an error object here because since vm.Script doesn't store a context, we can't properly contextify that error object.
159
+ // eslint-disable-next-line no-throw-literal
160
+ throw 'Dynamic imports are not allowed.'
161
+ }
162
+ })
205
163
  }
206
164
 
207
165
  function decorateErrorMessage (e, sourceFilesInfo) {
@@ -6,8 +6,6 @@ const REQUIRE_RESOLVE_CACHE = new Map()
6
6
  const REQUIRE_SCRIPT_CACHE = new Map()
7
7
  const PACKAGE_JSON_CACHE = new Map()
8
8
 
9
- let ALL_BUILTIN_MODULES
10
-
11
9
  // The isolated require is a function that replicates the node.js require but that does not
12
10
  // cache the modules with the standard node.js cache, instead its uses its own cache in order
13
11
  // to bring isolated modules across renders and without memory leaks.
@@ -24,7 +22,7 @@ function isolatedRequire (_moduleId, requireFromRootDirectory, isolatedModulesMe
24
22
  throw createInvalidArgValueError('id', moduleId, 'must be a non-empty string')
25
23
  }
26
24
 
27
- if (isBuiltinModule(moduleId)) {
25
+ if (Module.isBuiltin(moduleId)) {
28
26
  // built-in modules can not be require from other part than the node.js require
29
27
  // perhaps in the future it can be possible:
30
28
  // https://github.com/nodejs/node/issues/31852
@@ -174,24 +172,6 @@ function IsolatedModule (id = '', parent) {
174
172
  })
175
173
  }
176
174
 
177
- // NOTE: we can not use Module.isBuiltin because it is not available on node 16
178
- // we can upgrade our implementation to just use Module.isBuiltin when we drop support for node 16
179
- // https://github.com/nodejs/node/blob/v18.14.2/lib/internal/modules/cjs/loader.js#L252
180
- function isBuiltinModule (moduleId) {
181
- // use the standard function when available
182
- if (Module.isBuiltin) {
183
- return Module.isBuiltin(moduleId)
184
- }
185
-
186
- // the only version in which this code would run is node 16 and early versions of node 18
187
- // https://github.com/nodejs/node/blob/v18.14.2/lib/internal/modules/cjs/loader.js#L252
188
- if (!ALL_BUILTIN_MODULES) {
189
- ALL_BUILTIN_MODULES = new Set(Module.builtinModules.flatMap((bm) => [bm, `node:${bm}`]))
190
- }
191
-
192
- return ALL_BUILTIN_MODULES.has(moduleId)
193
- }
194
-
195
175
  // https://github.com/nodejs/node/blob/v18.14.2/lib/internal/modules/cjs/helpers.js#L65
196
176
  function makeRequireFunction (mod, requireFromRootDirectory, currentExtensions) {
197
177
  const requireFn = function require (path) {
@@ -1,7 +1,7 @@
1
1
  const extend = require('node.extend.without.arrays')
2
2
  const groupBy = require('lodash.groupby')
3
3
  const get = require('lodash.get')
4
- const set = require('lodash.set')
4
+ const set = require('set-value')
5
5
  const hasOwn = require('has-own-deep')
6
6
  const unsetValue = require('unset-value')
7
7
 
@@ -420,7 +420,7 @@ function sortPropertiesByLevel (a, b) {
420
420
  }
421
421
 
422
422
  function omitProp (context, prop) {
423
- // if property has value, then set it to undefined first,
423
+ // if property has value, then set it to some value first,
424
424
  // unsetValue expects that property has some non empty value to remove the property
425
425
  // so we set to "true" to ensure it works for all cases,
426
426
  // we use unsetValue instead of lodash.omit because