@tetherto/mdk-core 0.0.0 → 0.12.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.
Files changed (54) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +572 -0
  3. package/index.js +519 -0
  4. package/lib/address-policy.js +124 -0
  5. package/lib/local-discovery.js +321 -0
  6. package/lib/services/actions.service.js +68 -0
  7. package/lib/services/alerts.service.js +207 -0
  8. package/lib/services/comments.service.js +118 -0
  9. package/lib/services/log-history.service.js +307 -0
  10. package/lib/services/logs.service.js +249 -0
  11. package/lib/services/pool-utils/constants.js +20 -0
  12. package/lib/services/pool-utils/time.js +49 -0
  13. package/lib/services/pool.service.js +397 -0
  14. package/lib/services/provisioning.service.js +420 -0
  15. package/lib/services/settings.service.js +26 -0
  16. package/lib/services/snaps.service.js +181 -0
  17. package/lib/services/stats.service.js +205 -0
  18. package/lib/stats/README.md +80 -0
  19. package/lib/stats/index.js +57 -0
  20. package/lib/stats/ops/alerts_aggr.js +40 -0
  21. package/lib/stats/ops/alerts_group_cnt.js +43 -0
  22. package/lib/stats/ops/arr_concat.js +27 -0
  23. package/lib/stats/ops/array_obj_calc.js +41 -0
  24. package/lib/stats/ops/avg.js +42 -0
  25. package/lib/stats/ops/cnt.js +19 -0
  26. package/lib/stats/ops/group.js +38 -0
  27. package/lib/stats/ops/group_avg.js +53 -0
  28. package/lib/stats/ops/group_cnt.js +38 -0
  29. package/lib/stats/ops/group_max.js +40 -0
  30. package/lib/stats/ops/group_multiple_stats.js +40 -0
  31. package/lib/stats/ops/group_sum.js +40 -0
  32. package/lib/stats/ops/index.js +35 -0
  33. package/lib/stats/ops/nested_obj_concat.js +54 -0
  34. package/lib/stats/ops/obj_concat.js +33 -0
  35. package/lib/stats/ops/sum.js +27 -0
  36. package/lib/stats/utils.js +99 -0
  37. package/lib/templates/alerts.js +128 -0
  38. package/lib/templates/stats.js +476 -0
  39. package/lib/things/constants.js +65 -0
  40. package/lib/things/container.js +84 -0
  41. package/lib/things/miner.js +190 -0
  42. package/lib/things/powermeter.js +30 -0
  43. package/lib/things/sensor.js +18 -0
  44. package/lib/things/thing.js +93 -0
  45. package/lib/utils.js +603 -0
  46. package/lib/worker-infra.js +221 -0
  47. package/package.json +64 -1
  48. package/services.js +246 -0
  49. package/utils/compose-yaml.js +56 -0
  50. package/utils/constants.js +30 -0
  51. package/utils/index.js +29 -0
  52. package/utils/initialize.js +148 -0
  53. package/utils/service-bootstrap.js +271 -0
  54. package/worker.js +9 -0
package/lib/utils.js ADDED
@@ -0,0 +1,603 @@
1
+ 'use strict'
2
+
3
+ const crypto = require('crypto')
4
+ const mingo = require('mingo')
5
+ const gLibUtilBase = require('@bitfinex/lib-js-util-base')
6
+
7
+ const isValidSnap = (snap) => {
8
+ return snap.stats && snap.config
9
+ }
10
+
11
+ const isOffline = (snap) => {
12
+ return !snap?.stats?.status || snap.stats.status === 'offline'
13
+ }
14
+
15
+ function getRandomString (length) {
16
+ return crypto.randomBytes(length)
17
+ .toString('base64')
18
+ .replace(/[^a-z0-9]/gi, '')
19
+ .slice(0, length)
20
+ }
21
+
22
+ function hasErrorAndPositiveHashrate (entry) {
23
+ const { errors, hashrate_mhs: hashrateMHS } = entry?.last?.snap?.stats || {}
24
+ const hashrate = hashrateMHS?.t_5m
25
+
26
+ return errors?.length && hashrate > 0
27
+ }
28
+
29
+ function sumPoolsShares (pools, key) {
30
+ if (!Array.isArray(pools)) return 0
31
+
32
+ return pools?.reduce((acc, pool) => {
33
+ const value = parseInt(pool[key])
34
+ return acc + (isNaN(value) ? 0 : value)
35
+ }, 0)
36
+ }
37
+
38
+ const TIME_PERIODS_MS = {
39
+ H: 60 * 60 * 1000,
40
+ D: 24 * 60 * 60 * 1000,
41
+ W: 7 * 24 * 60 * 60 * 1000,
42
+ M: 30 * 24 * 60 * 60 * 1000
43
+ }
44
+
45
+ const getIntervalFromCron = (cron) => {
46
+ const [, minute, hour] = cron.split(' ')
47
+
48
+ if (minute.startsWith('*/')) {
49
+ return parseInt(minute.slice(2), 10) * 60 * 1000
50
+ }
51
+
52
+ if (hour.startsWith('*/')) {
53
+ return parseInt(hour.slice(2), 10) * 60 * 60 * 1000
54
+ }
55
+
56
+ if (hour === '0' && minute === '0') {
57
+ return 24 * 60 * 60 * 1000
58
+ }
59
+ throw new Error('ERR_CRON_UNSUPPORTED')
60
+ }
61
+
62
+ const getLogsCountForTimeRange = (start, end, key, statTimeframes) => {
63
+ end ||= Date.now()
64
+ start ??= 0
65
+ if (!statTimeframes) return 0
66
+ const currentLogCron = statTimeframes.find(
67
+ tf => tf[0] === key.replace('stat-', '')
68
+ )?.[1]
69
+ if (!currentLogCron) return 0
70
+ try {
71
+ const timeInterval = getIntervalFromCron(currentLogCron)
72
+ return Math.ceil((end - start) / timeInterval) + 1
73
+ } catch (error) {
74
+ return 0
75
+ }
76
+ }
77
+
78
+ const isObject = obj => {
79
+ return obj && typeof obj === 'object' && !Array.isArray(obj)
80
+ }
81
+
82
+ const compareArrays = (prevArray, currArray) => {
83
+ const additions = currArray.filter(item => !prevArray.includes(item))
84
+ const deletions = prevArray.filter(item => !currArray.includes(item))
85
+ return { additions, deletions }
86
+ }
87
+
88
+ const compareObjects = (prev, curr, changes, path = '') => {
89
+ const allKeys = new Set([
90
+ ...Object.keys(prev || {}),
91
+ ...Object.keys(curr || {})
92
+ ])
93
+
94
+ allKeys.forEach(key => {
95
+ const currentPath = path ? `${path}.${key}` : key
96
+ const prevValue = prev ? prev[key] : undefined
97
+ const currValue = curr ? curr[key] : undefined
98
+
99
+ if (isObject(prevValue) && isObject(currValue)) {
100
+ compareObjects(prevValue, currValue, changes, currentPath)
101
+ } else if (Array.isArray(prevValue) && Array.isArray(currValue)) {
102
+ const arrayChanges = compareArrays(prevValue, currValue)
103
+ if (
104
+ arrayChanges.additions.length > 0 ||
105
+ arrayChanges.deletions.length > 0
106
+ ) {
107
+ changes[currentPath] = arrayChanges
108
+ }
109
+ } else if (prevValue !== currValue) {
110
+ changes[currentPath] = {
111
+ oldValue: prevValue,
112
+ newValue: currValue
113
+ }
114
+ }
115
+ })
116
+ }
117
+
118
+ const getJsonChanges = (previousJson, currentJson) => {
119
+ const changes = {}
120
+
121
+ compareObjects(previousJson, currentJson, changes)
122
+ return changes
123
+ }
124
+
125
+ const getLogMaxHeight = (logKeepCount = 3) => Math.ceil(logKeepCount * 1.5)
126
+
127
+ const aggregateLogs = (logs, groupRange, shouldCalculateAvg = false) => {
128
+ if (!logs || logs.length === 0) return []
129
+
130
+ const rangeMs = parseGroupRange(groupRange)
131
+ const buckets = groupByTimeBuckets(logs, rangeMs)
132
+
133
+ return buckets.map(bucket => aggregateBucket(bucket, shouldCalculateAvg ? 'avg' : 'sum'))
134
+ }
135
+
136
+ const parseGroupRange = (range) => {
137
+ const match = /^(\d+)\s*([HDWM])s?$/i.exec(range.trim())
138
+ if (!match) {
139
+ throw new Error(
140
+ 'ERR_INVALID_GROUP_RANGE_FORMAT. Use formats like "1D", "1H", "1W", "1M"'
141
+ )
142
+ }
143
+
144
+ const value = Number(match[1])
145
+ const unit = match[2].toUpperCase()
146
+ return value * TIME_PERIODS_MS[unit]
147
+ }
148
+
149
+ const groupByTimeBuckets = (logs, rangeMs) => {
150
+ if (logs.length === 0) return []
151
+ const sorted = [...logs].sort((a, b) => a.ts - b.ts)
152
+
153
+ const minTs = sorted[0].ts
154
+ const maxTs = sorted[sorted.length - 1].ts
155
+
156
+ const buckets = []
157
+ let bucketStart = Math.floor(minTs / rangeMs) * rangeMs
158
+
159
+ while (bucketStart <= maxTs) {
160
+ const bucketEnd = bucketStart + rangeMs
161
+ const logsInBucket = sorted.filter(log => log.ts >= bucketStart && log.ts < bucketEnd)
162
+
163
+ if (logsInBucket.length > 0) {
164
+ buckets.push({
165
+ start: bucketStart,
166
+ end: bucketEnd - 1,
167
+ logs: logsInBucket
168
+ })
169
+ }
170
+
171
+ bucketStart = bucketEnd
172
+ }
173
+
174
+ return buckets
175
+ }
176
+
177
+ const aggregateBucket = (bucket, operation) => {
178
+ const { start, end, logs } = bucket
179
+
180
+ if (logs.length === 0) return null
181
+
182
+ if (logs.length === 1) {
183
+ return {
184
+ ...logs[0],
185
+ ts: `${start}-${end}`
186
+ }
187
+ }
188
+
189
+ const result = {
190
+ ts: `${start}-${end}`
191
+ }
192
+
193
+ const allKeys = new Set()
194
+ logs.forEach(log => {
195
+ Object.keys(log).forEach(key => {
196
+ if (key !== 'ts') allKeys.add(key)
197
+ })
198
+ })
199
+
200
+ for (const key of allKeys) {
201
+ result[key] = aggregateField(logs, key, operation)
202
+ }
203
+
204
+ return result
205
+ }
206
+
207
+ const aggregateField = (logs, fieldName, operation) => {
208
+ const values = logs
209
+ .map(log => log[fieldName])
210
+ .filter(v => v !== undefined && v !== null)
211
+
212
+ if (values.length === 0) return null
213
+
214
+ const firstValue = values[0]
215
+
216
+ if (typeof firstValue === 'object' && !Array.isArray(firstValue)) {
217
+ return aggregateNestedObject(logs, fieldName, operation)
218
+ }
219
+
220
+ if (typeof firstValue === 'number') {
221
+ return aggregateNumeric(values, operation)
222
+ }
223
+
224
+ return firstValue
225
+ }
226
+
227
+ const aggregateNestedObject = (logs, fieldName, operation) => {
228
+ const allKeys = new Set()
229
+ logs.forEach(log => {
230
+ if (log[fieldName] && typeof log[fieldName] === 'object') {
231
+ Object.keys(log[fieldName]).forEach(k => allKeys.add(k))
232
+ }
233
+ })
234
+
235
+ const result = {}
236
+
237
+ for (const key of allKeys) {
238
+ const values = logs
239
+ .map(log => log[fieldName]?.[key])
240
+ .filter(v => v !== undefined && v !== null && typeof v === 'number')
241
+
242
+ if (values.length > 0) {
243
+ result[key] = aggregateNumeric(values, operation)
244
+ } else {
245
+ const firstValue = logs.find(log => log[fieldName]?.[key] !== undefined && log[fieldName]?.[key] !== null)
246
+ if (firstValue) {
247
+ result[key] = firstValue[fieldName][key]
248
+ }
249
+ }
250
+ }
251
+
252
+ return Object.keys(result).length > 0 ? result : null
253
+ }
254
+
255
+ const aggregateNumeric = (values, operation) => {
256
+ const numericValues = values.filter(v => typeof v === 'number' && !isNaN(v))
257
+
258
+ if (numericValues.length === 0) return 0
259
+
260
+ if (operation === 'sum') {
261
+ return numericValues.reduce((sum, val) => sum + val, 0)
262
+ } else if (operation === 'avg') {
263
+ const sum = numericValues.reduce((sum, val) => sum + val, 0)
264
+ return sum / numericValues.length
265
+ }
266
+
267
+ throw new Error(`ERR_UNKNOWN_OPERATION: ${operation}. Use 'avg' or 'sum'`)
268
+ }
269
+
270
+ function getValue (obj, path) {
271
+ const keys = path.split('.')
272
+ return keys.reduce((acc, key) => acc && acc[key], obj)
273
+ }
274
+
275
+ const getThingSorter = (a, b, sortBy) => {
276
+ const regex = /\d+|\D+/g
277
+
278
+ if (!sortBy || Object.keys(sortBy).length === 0) {
279
+ return 1
280
+ }
281
+
282
+ for (const [key, order] of Object.entries(sortBy)) {
283
+ const valA = getValue(a, key)
284
+ const valB = getValue(b, key)
285
+
286
+ if (valA === undefined || valB === undefined) {
287
+ return (valA === undefined) - (valB === undefined)
288
+ }
289
+
290
+ const parseValue = value => String(value).match(regex) || []
291
+ const partsA = parseValue(valA)
292
+ const partsB = parseValue(valB)
293
+
294
+ for (let i = 0; i < Math.min(partsA.length, partsB.length); i++) {
295
+ const [partA, partB] = [partsA[i], partsB[i]]
296
+
297
+ const diff =
298
+ !isNaN(partA) && !isNaN(partB)
299
+ ? Number(partA) - Number(partB)
300
+ : partA.localeCompare(partB)
301
+
302
+ if (diff !== 0) {
303
+ return diff * order
304
+ }
305
+ }
306
+
307
+ const lengthDiff = partsA.length - partsB.length
308
+ if (lengthDiff !== 0) {
309
+ return lengthDiff * order
310
+ }
311
+ }
312
+
313
+ return 0
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Secret redaction + caller-query hardening
318
+ //
319
+ // Device records keep credentials in `opts` (password, apiKey, ...) and pool
320
+ // configs in `pools[]`. Anything handed back to a caller goes through
321
+ // redactSecrets(), and caller-supplied mingo queries are validated by
322
+ // sanitizeQuery() — an operator allowlist plus a path denylist — so a filter
323
+ // can neither read a secret nor be used as a character-by-character oracle
324
+ // against one ({'opts.password': {$regex: '^a'}}).
325
+ // ---------------------------------------------------------------------------
326
+
327
+ const REDACTED = '[REDACTED]'
328
+
329
+ // Exact (case-insensitive) secret key names, plus suffixes that mark a key as
330
+ // a secret wherever it appears (adminPassword, apiSecret, authToken, ...).
331
+ const SECRET_KEYS = new Set([
332
+ 'password', 'pass', 'passwd', 'pwd', 'apikey', 'api_key', 'token', 'secret',
333
+ 'privatekey', 'private_key', 'seed', 'mnemonic', 'apisecret', 'api_secret'
334
+ ])
335
+ const SECRET_SUFFIX_RE = /(password|passwd|secret|token|apikey|api_key|privatekey|private_key|mnemonic)$/i
336
+
337
+ function isSecretKey (key) {
338
+ if (typeof key !== 'string') return false
339
+ return SECRET_KEYS.has(key.toLowerCase()) || SECRET_SUFFIX_RE.test(key)
340
+ }
341
+
342
+ /**
343
+ * Deep copy of `value` with every secret-named key's value replaced by
344
+ * REDACTED. Non-plain values (Buffers, Dates) are returned as-is.
345
+ */
346
+ function redactSecrets (value, depth = 0) {
347
+ if (depth > 32 || value === null || typeof value !== 'object') return value
348
+ if (Array.isArray(value)) return value.map((v) => redactSecrets(v, depth + 1))
349
+ const proto = Object.getPrototypeOf(value)
350
+ if (proto !== Object.prototype && proto !== null) return value
351
+ const out = {}
352
+ for (const [k, v] of Object.entries(value)) {
353
+ out[k] = isSecretKey(k) && v !== undefined && v !== null && v !== '' ? REDACTED : redactSecrets(v, depth + 1)
354
+ }
355
+ return out
356
+ }
357
+
358
+ /**
359
+ * Removes REDACTED placeholders a caller echoed back (e.g. an edit form fed
360
+ * from a redacted getThingConf), so they never overwrite a stored secret.
361
+ */
362
+ function stripRedacted (value, depth = 0) {
363
+ if (depth > 32 || value === null || typeof value !== 'object') return value
364
+ if (Array.isArray(value)) return value.map((v) => stripRedacted(v, depth + 1))
365
+ const out = {}
366
+ for (const [k, v] of Object.entries(value)) {
367
+ if (v === REDACTED) continue
368
+ out[k] = stripRedacted(v, depth + 1)
369
+ }
370
+ return out
371
+ }
372
+
373
+ // Operators a caller query may use. $where/$expr/$function/$accumulator and
374
+ // friends evaluate expressions or code and are never accepted. $regex is
375
+ // accepted (shipped UIs search with it) but only as a literal search — see
376
+ // _safeRegex.
377
+ const ALLOWED_QUERY_OPS = new Set([
378
+ '$eq', '$ne', '$in', '$nin', '$exists', '$gt', '$gte', '$lt', '$lte',
379
+ '$and', '$or', '$nor', '$not', '$elemMatch', '$all', '$size',
380
+ '$regex', '$options'
381
+ ])
382
+ const MAX_QUERY_DEPTH = 8
383
+ const MAX_QUERY_NODES = 500
384
+ const MAX_REGEX_LENGTH = 256
385
+ const REGEX_META_RE = /[\\^$.*+?()[\]{}|/-]/g
386
+ const FORBIDDEN_PATH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype'])
387
+ // The non-secret connection fields under `opts` a caller may filter on (the UI's
388
+ // IP search sends `opts.address`). Every other `opts.*` path stays refused.
389
+ const QUERYABLE_OPTS = new Set(['address', 'port'])
390
+
391
+ function _checkPath (p) {
392
+ if (typeof p !== 'string' || !p.length || p.length > 256) throw new Error('ERR_QUERY_PATH_INVALID')
393
+ const segs = p.split('.')
394
+ if (segs[0] === 'opts' && !(segs.length === 2 && QUERYABLE_OPTS.has(segs[1]))) {
395
+ throw new Error(`ERR_QUERY_PATH_FORBIDDEN: ${p}`)
396
+ }
397
+ for (const seg of segs) {
398
+ if (FORBIDDEN_PATH_SEGMENTS.has(seg) || isSecretKey(seg)) {
399
+ throw new Error(`ERR_QUERY_PATH_FORBIDDEN: ${p}`)
400
+ }
401
+ }
402
+ }
403
+
404
+ function escapeRegex (str) {
405
+ return str.replace(REGEX_META_RE, '\\$&')
406
+ }
407
+
408
+ /**
409
+ * A caller `$regex` is a literal search, never a pattern: every regex
410
+ * metacharacter is escaped, except an optional leading `^` (prefix match) and
411
+ * trailing `$` (suffix match). The only option is `i`. This keeps the
412
+ * shipped searches (`^miner-`, `^AA:BB:..$` with `i`, substring search) and
413
+ * makes catastrophic backtracking impossible. Returns { source, flags }.
414
+ */
415
+ function _safeRegex (pattern, options) {
416
+ let src = pattern
417
+ let flags = options
418
+ if (pattern instanceof RegExp) {
419
+ src = pattern.source
420
+ if (flags === undefined) flags = pattern.flags
421
+ }
422
+ if (typeof src !== 'string' || src.length > MAX_REGEX_LENGTH) throw new Error('ERR_QUERY_REGEX_INVALID')
423
+ if (flags !== undefined && flags !== null && (typeof flags !== 'string' || !/^i?$/.test(flags))) {
424
+ throw new Error('ERR_QUERY_REGEX_INVALID')
425
+ }
426
+ const head = src.startsWith('^') ? '^' : ''
427
+ let body = head ? src.slice(1) : src
428
+ const tail = body.endsWith('$') ? '$' : ''
429
+ if (tail) body = body.slice(0, -1)
430
+ return { source: head + escapeRegex(body) + tail, flags: flags || '' }
431
+ }
432
+
433
+ function _safeRegExp (pattern) {
434
+ const { source, flags } = _safeRegex(pattern)
435
+ return new RegExp(source, flags)
436
+ }
437
+
438
+ /**
439
+ * Validates a caller-supplied mingo query in place of evaluating it blindly:
440
+ * only allowlisted operators, no paths under `opts.*` or naming a secret
441
+ * field, bounded depth/size. Every `$regex` (and any RegExp value) is
442
+ * rewritten to a literal search (see _safeRegex). Throws ERR_QUERY_* on the
443
+ * first violation; returns a sanitized copy otherwise (the input is not
444
+ * modified).
445
+ */
446
+ function sanitizeQuery (query) {
447
+ let nodes = 0
448
+ const isPlain = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && !(v instanceof RegExp)
449
+ const isOpObject = (v) => isPlain(v) && Object.keys(v).some((k) => k.startsWith('$'))
450
+
451
+ const checkOp = (k) => {
452
+ if (!ALLOWED_QUERY_OPS.has(k)) throw new Error(`ERR_QUERY_OPERATOR_FORBIDDEN: ${k}`)
453
+ }
454
+
455
+ // `doc` level: keys are field paths or logical operators.
456
+ const walkDoc = (node, depth) => {
457
+ if (++nodes > MAX_QUERY_NODES || depth > MAX_QUERY_DEPTH) throw new Error('ERR_QUERY_TOO_COMPLEX')
458
+ if (!isPlain(node)) throw new Error('ERR_QUERY_INVALID')
459
+ for (const [k, v] of Object.entries(node)) {
460
+ if (k === '$and' || k === '$or' || k === '$nor') {
461
+ if (!Array.isArray(v)) throw new Error('ERR_QUERY_INVALID')
462
+ for (const sub of v) walkDoc(sub, depth + 1)
463
+ continue
464
+ }
465
+ if (k.startsWith('$')) throw new Error(`ERR_QUERY_OPERATOR_FORBIDDEN: ${k}`)
466
+ _checkPath(k)
467
+ if (v instanceof RegExp) node[k] = _safeRegExp(v)
468
+ else if (isOpObject(v)) walkExpr(v, depth + 1)
469
+ }
470
+ }
471
+
472
+ // `expr` level: keys are operators applied to one field's value; anything
473
+ // else in there is a literal value mingo compares, never evaluates.
474
+ const walkExpr = (node, depth) => {
475
+ if (++nodes > MAX_QUERY_NODES || depth > MAX_QUERY_DEPTH) throw new Error('ERR_QUERY_TOO_COMPLEX')
476
+ for (const [k, v] of Object.entries(node)) {
477
+ if (!k.startsWith('$')) continue
478
+ checkOp(k)
479
+ if (k === '$regex') {
480
+ const { source, flags } = _safeRegex(v, node.$options)
481
+ node.$regex = source
482
+ if (flags) node.$options = flags
483
+ else delete node.$options
484
+ } else if (k === '$not') {
485
+ if (v instanceof RegExp) node.$not = _safeRegExp(v)
486
+ else if (isPlain(v)) walkExpr(v, depth + 1)
487
+ } else if (k === '$elemMatch') {
488
+ if (isOpObject(v) && Object.keys(v).every((x) => x.startsWith('$') && !['$and', '$or', '$nor'].includes(x))) walkExpr(v, depth + 1)
489
+ else walkDoc(v, depth + 1)
490
+ } else if ((k === '$in' || k === '$nin' || k === '$all') && Array.isArray(v)) {
491
+ for (let i = 0; i < v.length; i++) {
492
+ if (v[i] instanceof RegExp) v[i] = _safeRegExp(v[i])
493
+ else if (k === '$all' && isOpObject(v[i])) walkExpr(v[i], depth + 1)
494
+ }
495
+ } else if (v instanceof RegExp) {
496
+ node[k] = _safeRegExp(v)
497
+ }
498
+ }
499
+ }
500
+
501
+ if (query === undefined || query === null) return query
502
+ if (!isPlain(query)) throw new Error('ERR_QUERY_INVALID')
503
+ // Rewritten in a copy so the caller's object (often reused by the caller,
504
+ // or a shared constant) is never modified.
505
+ let copy
506
+ try {
507
+ copy = structuredClone(query)
508
+ } catch (e) {
509
+ throw new Error('ERR_QUERY_INVALID')
510
+ }
511
+ walkDoc(copy, 0)
512
+ return copy
513
+ }
514
+
515
+ function _sanitizeFields (fields) {
516
+ if (fields === undefined || fields === null) return fields
517
+ if (typeof fields !== 'object' || Array.isArray(fields)) throw new Error('ERR_QUERY_FIELDS_INVALID')
518
+ for (const [k, v] of Object.entries(fields)) {
519
+ if (k.startsWith('$')) throw new Error(`ERR_QUERY_OPERATOR_FORBIDDEN: ${k}`)
520
+ if (!(v === 0 || v === 1 || v === true || v === false)) throw new Error('ERR_QUERY_FIELDS_INVALID')
521
+ for (const seg of k.split('.')) {
522
+ if (FORBIDDEN_PATH_SEGMENTS.has(seg)) throw new Error(`ERR_QUERY_PATH_FORBIDDEN: ${k}`)
523
+ }
524
+ }
525
+ return fields
526
+ }
527
+
528
+ function _sanitizeSort (sort) {
529
+ if (sort === undefined || sort === null) return sort
530
+ if (typeof sort !== 'object' || Array.isArray(sort)) throw new Error('ERR_QUERY_SORT_INVALID')
531
+ for (const [k, v] of Object.entries(sort)) {
532
+ _checkPath(k)
533
+ if (v !== 1 && v !== -1) throw new Error('ERR_QUERY_SORT_INVALID')
534
+ }
535
+ return sort
536
+ }
537
+
538
+ function _toNonNegInt (v, name) {
539
+ if (v === undefined || v === null) return v
540
+ const n = Number(v)
541
+ if (!Number.isInteger(n) || n < 0) throw new Error(`ERR_QUERY_${name}_INVALID`)
542
+ return n
543
+ }
544
+
545
+ /**
546
+ * Caller-facing filter/sort/page over `things`. `req.query`, `req.fields`,
547
+ * `req.sort`, `req.offset` and `req.limit` usually come straight from a
548
+ * remote caller and are validated here (see sanitizeQuery). Callers holding
549
+ * secret-bearing records must pass a redacted projection (redactSecrets).
550
+ */
551
+ function applyFilters (things, req, returnObjects = false) {
552
+ const query = sanitizeQuery(req.query)
553
+ const fields = _sanitizeFields(req.fields)
554
+ const sort = _sanitizeSort(req.sort)
555
+ const offset = _toNonNegInt(req.offset, 'OFFSET')
556
+ const limit = _toNonNegInt(req.limit, 'LIMIT')
557
+
558
+ if (!gLibUtilBase.isNil(query) || !gLibUtilBase.isNil(fields)) {
559
+ const q = new mingo.Query(query || {}, { scriptEnabled: false })
560
+ things = q.find(things, fields || {}).all()
561
+ }
562
+ if (!gLibUtilBase.isNil(sort)) {
563
+ things = things.sort((a, b) => getThingSorter(a, b, sort))
564
+ }
565
+ if (offset) {
566
+ things = things.slice(offset)
567
+ }
568
+ if (!gLibUtilBase.isNil(limit)) {
569
+ things = things.slice(0, limit)
570
+ }
571
+
572
+ if (returnObjects) return things
573
+
574
+ return things.map(e => e.id)
575
+ }
576
+
577
+ function projection (data, fields = {}) {
578
+ _sanitizeFields(fields)
579
+ const query = new mingo.Query({}, { scriptEnabled: false })
580
+ const cursor = query.find(data, fields)
581
+ return cursor.all()
582
+ }
583
+
584
+ module.exports = {
585
+ isValidSnap,
586
+ isOffline,
587
+ getRandomString,
588
+ hasErrorAndPositiveHashrate,
589
+ sumPoolsShares,
590
+ getLogsCountForTimeRange,
591
+ getLogMaxHeight,
592
+ getJsonChanges,
593
+ aggregateLogs,
594
+ getThingSorter,
595
+ applyFilters,
596
+ projection,
597
+ sanitizeQuery,
598
+ escapeRegex,
599
+ redactSecrets,
600
+ stripRedacted,
601
+ isSecretKey,
602
+ REDACTED
603
+ }