sounding 0.0.3 → 0.1.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,633 @@
1
+ const { createSoundingError } = require('./create-error')
2
+
3
+ /** @typedef {import('./types').AnyRecord} AnyRecord */
4
+ /** @typedef {import('./types').SoundingConfig} SoundingConfig */
5
+
6
+ const TOP_LEVEL_KEYS = [
7
+ 'environments',
8
+ 'app',
9
+ 'world',
10
+ 'datastore',
11
+ 'browser',
12
+ 'mail',
13
+ 'request',
14
+ 'sockets',
15
+ 'auth',
16
+ ]
17
+
18
+ const SECTION_KEYS = {
19
+ app: ['path', 'environment', 'quiet', 'loadOptions', 'liftOptions'],
20
+ world: ['factories', 'scenarios'],
21
+ datastore: ['mode', 'identity', 'adapter', 'root', 'isolation'],
22
+ browser: ['enabled', 'type', 'projects', 'defaultProject', 'baseUrl', 'launchOptions', 'artifacts'],
23
+ mail: ['capture', 'layout', 'deliver', 'mode'],
24
+ request: ['transport', 'baseUrl'],
25
+ sockets: [
26
+ 'enabled',
27
+ 'timeout',
28
+ 'transports',
29
+ 'path',
30
+ 'baseUrl',
31
+ 'headers',
32
+ 'initialConnectionHeaders',
33
+ ],
34
+ auth: ['defaultActor', 'modelIdentity', 'sessionKey', 'worldCollection', 'password'],
35
+ }
36
+
37
+ const PASSWORD_KEYS = ['loginPath', 'pagePath', 'pageQuery', 'form', 'selectors']
38
+ const PASSWORD_FORM_KEYS = ['email', 'password', 'rememberMe', 'returnUrl']
39
+ const PASSWORD_SELECTOR_KEYS = ['email', 'password', 'rememberMe', 'submit']
40
+
41
+ const DATASTORE_MODES = ['managed', 'inherit', 'external']
42
+ const DATASTORE_ISOLATION = ['worker', 'run']
43
+ const REQUEST_TRANSPORTS = ['virtual', 'http']
44
+ const BROWSER_TYPES = ['chromium', 'firefox', 'webkit']
45
+ const BROWSER_PROJECT_KEYS = ['name', 'type', 'device', 'viewport', 'contextOptions', 'launchOptions']
46
+ const BROWSER_PROJECT_VIEWPORT_KEYS = ['width', 'height']
47
+ const BROWSER_ARTIFACT_KEYS = ['outputDir', 'screenshot', 'trace', 'video', 'currentUrl']
48
+ const BROWSER_ARTIFACT_MODES = ['off', 'on', 'on-failure']
49
+ const MAIL_MODES = ['capture', 'passthrough']
50
+
51
+ /**
52
+ * @param {any} value
53
+ * @returns {value is AnyRecord}
54
+ */
55
+ function isPlainObject(value) {
56
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
57
+ }
58
+
59
+ /**
60
+ * @param {string} value
61
+ * @param {string} candidate
62
+ * @returns {number}
63
+ */
64
+ function editDistance(value, candidate) {
65
+ const rows = value.length + 1
66
+ const columns = candidate.length + 1
67
+ const matrix = Array.from({ length: rows }, () => Array(columns).fill(0))
68
+
69
+ for (let row = 0; row < rows; row += 1) {
70
+ matrix[row][0] = row
71
+ }
72
+
73
+ for (let column = 0; column < columns; column += 1) {
74
+ matrix[0][column] = column
75
+ }
76
+
77
+ for (let row = 1; row < rows; row += 1) {
78
+ for (let column = 1; column < columns; column += 1) {
79
+ const cost = value[row - 1] === candidate[column - 1] ? 0 : 1
80
+ matrix[row][column] = Math.min(
81
+ matrix[row - 1][column] + 1,
82
+ matrix[row][column - 1] + 1,
83
+ matrix[row - 1][column - 1] + cost
84
+ )
85
+ }
86
+ }
87
+
88
+ return matrix[value.length][candidate.length]
89
+ }
90
+
91
+ /**
92
+ * @param {string} key
93
+ * @param {string[]} allowed
94
+ * @returns {string | null}
95
+ */
96
+ function findSuggestion(key, allowed) {
97
+ let best = null
98
+ let bestDistance = Infinity
99
+
100
+ for (const candidate of allowed) {
101
+ const distance = editDistance(key.toLowerCase(), candidate.toLowerCase())
102
+
103
+ if (distance < bestDistance) {
104
+ best = candidate
105
+ bestDistance = distance
106
+ }
107
+ }
108
+
109
+ return bestDistance <= 2 ? best : null
110
+ }
111
+
112
+ /**
113
+ * @param {any} value
114
+ * @returns {string}
115
+ */
116
+ function describeValue(value) {
117
+ if (Array.isArray(value)) {
118
+ return 'array'
119
+ }
120
+
121
+ if (value === null) {
122
+ return 'null'
123
+ }
124
+
125
+ return typeof value
126
+ }
127
+
128
+ /**
129
+ * @param {string} path
130
+ * @param {string} message
131
+ * @param {{ value?: any, allowed?: string[], suggestion?: string }} [details]
132
+ * @returns {never}
133
+ */
134
+ function throwConfigError(path, message, details = {}) {
135
+ const errorDetails = {
136
+ path,
137
+ }
138
+
139
+ if (Object.prototype.hasOwnProperty.call(details, 'value')) {
140
+ errorDetails.value = details.value
141
+ }
142
+
143
+ if (details.allowed !== undefined) {
144
+ errorDetails.allowed = details.allowed
145
+ }
146
+
147
+ if (details.suggestion) {
148
+ errorDetails.suggestion = details.suggestion
149
+ }
150
+
151
+ throw createSoundingError({
152
+ code: 'E_SOUNDING_CONFIG_INVALID',
153
+ name: 'SoundingConfigError',
154
+ message: `Invalid Sounding config at \`${path}\`: ${message}`,
155
+ details: errorDetails,
156
+ })
157
+ }
158
+
159
+ /**
160
+ * @param {AnyRecord} legacy
161
+ * @returns {string}
162
+ */
163
+ function buildLegacyManagedDatastoreSuggestion(legacy) {
164
+ const nextEntries = [`mode: 'managed'`]
165
+ const root = legacy.root ?? legacy.directory
166
+
167
+ if (legacy.adapter !== undefined) {
168
+ nextEntries.push(`adapter: ${JSON.stringify(legacy.adapter)}`)
169
+ }
170
+
171
+ if (root !== undefined) {
172
+ nextEntries.push(`root: ${JSON.stringify(root)}`)
173
+ }
174
+
175
+ if (legacy.isolation !== undefined) {
176
+ nextEntries.push(`isolation: ${JSON.stringify(legacy.isolation)}`)
177
+ }
178
+
179
+ return `Use \`datastore: { ${nextEntries.join(', ')} }\` instead.`
180
+ }
181
+
182
+ /**
183
+ * @param {AnyRecord} datastore
184
+ */
185
+ function assertNoLegacyDatastoreConfig(datastore) {
186
+ if (Object.prototype.hasOwnProperty.call(datastore, 'managed')) {
187
+ const legacy = isPlainObject(datastore.managed) ? datastore.managed : {}
188
+
189
+ throwConfigError(
190
+ 'sounding.datastore.managed',
191
+ 'legacy managed datastore config is no longer supported.',
192
+ {
193
+ value: datastore.managed,
194
+ suggestion: buildLegacyManagedDatastoreSuggestion(legacy),
195
+ }
196
+ )
197
+ }
198
+
199
+ if (Object.prototype.hasOwnProperty.call(datastore, 'directory')) {
200
+ throwConfigError(
201
+ 'sounding.datastore.directory',
202
+ 'legacy datastore directory config is no longer supported.',
203
+ {
204
+ value: datastore.directory,
205
+ suggestion: 'Use `sounding.datastore.root` instead.',
206
+ }
207
+ )
208
+ }
209
+ }
210
+
211
+ /**
212
+ * @param {AnyRecord} object
213
+ * @param {string} path
214
+ * @param {string[]} allowed
215
+ */
216
+ function assertKnownKeys(object, path, allowed) {
217
+ for (const key of Object.keys(object)) {
218
+ if (allowed.includes(key)) {
219
+ continue
220
+ }
221
+
222
+ const suggestion = findSuggestion(key, allowed)
223
+ throwConfigError(
224
+ `${path}.${key}`,
225
+ suggestion
226
+ ? `unknown option. Did you mean \`${path}.${suggestion}\`?`
227
+ : `unknown option. Allowed options are ${allowed.map((entry) => `\`${entry}\``).join(', ')}.`,
228
+ {
229
+ value: object[key],
230
+ allowed,
231
+ suggestion: suggestion ? `Did you mean \`${path}.${suggestion}\`?` : undefined,
232
+ }
233
+ )
234
+ }
235
+ }
236
+
237
+ /**
238
+ * @param {AnyRecord} config
239
+ * @param {string} path
240
+ */
241
+ function assertSection(config, path) {
242
+ if (!isPlainObject(config)) {
243
+ throwConfigError(path, `must be an object, received ${describeValue(config)}.`, {
244
+ value: config,
245
+ })
246
+ }
247
+ }
248
+
249
+ /**
250
+ * @param {any} value
251
+ * @param {string} path
252
+ */
253
+ function assertString(value, path) {
254
+ if (typeof value !== 'string') {
255
+ throwConfigError(path, `must be a string, received ${describeValue(value)}.`, {
256
+ value,
257
+ })
258
+ }
259
+ }
260
+
261
+ /**
262
+ * @param {any} value
263
+ * @param {string} path
264
+ */
265
+ function assertNullableString(value, path) {
266
+ if (value !== null && typeof value !== 'string') {
267
+ throwConfigError(path, `must be a string or null, received ${describeValue(value)}.`, {
268
+ value,
269
+ })
270
+ }
271
+ }
272
+
273
+ /**
274
+ * @param {any} value
275
+ * @param {string} path
276
+ */
277
+ function assertBoolean(value, path) {
278
+ if (typeof value !== 'boolean') {
279
+ throwConfigError(path, `must be a boolean, received ${describeValue(value)}.`, {
280
+ value,
281
+ })
282
+ }
283
+ }
284
+
285
+ /**
286
+ * @param {any} value
287
+ * @param {string} path
288
+ * @param {string[]} allowed
289
+ * @param {string} [suggestion]
290
+ */
291
+ function assertOneOf(value, path, allowed, suggestion) {
292
+ if (!allowed.includes(value)) {
293
+ throwConfigError(path, `must be one of ${allowed.map((entry) => `\`${entry}\``).join(', ')}.`, {
294
+ value,
295
+ allowed,
296
+ suggestion,
297
+ })
298
+ }
299
+ }
300
+
301
+ /**
302
+ * @param {any} value
303
+ * @param {string} path
304
+ */
305
+ function assertPlainObject(value, path) {
306
+ if (!isPlainObject(value)) {
307
+ throwConfigError(path, `must be an object, received ${describeValue(value)}.`, {
308
+ value,
309
+ })
310
+ }
311
+ }
312
+
313
+ /**
314
+ * @param {any} value
315
+ * @param {string} path
316
+ */
317
+ function assertBrowserArtifactSetting(value, path) {
318
+ if (typeof value === 'boolean') {
319
+ return
320
+ }
321
+
322
+ assertOneOf(value, path, BROWSER_ARTIFACT_MODES)
323
+ }
324
+
325
+ /**
326
+ * @param {any} value
327
+ * @param {string} path
328
+ */
329
+ function assertStringArray(value, path) {
330
+ if (!Array.isArray(value)) {
331
+ throwConfigError(path, `must be an array of strings, received ${describeValue(value)}.`, {
332
+ value,
333
+ })
334
+ }
335
+
336
+ value.forEach((entry, index) => {
337
+ assertString(entry, `${path}[${index}]`)
338
+ })
339
+ }
340
+
341
+ /**
342
+ * @param {any} viewport
343
+ * @param {string} path
344
+ */
345
+ function assertBrowserProjectViewport(viewport, path) {
346
+ assertPlainObject(viewport, path)
347
+ assertKnownKeys(viewport, path, BROWSER_PROJECT_VIEWPORT_KEYS)
348
+ assertPositiveNumber(viewport.width, `${path}.width`)
349
+ assertPositiveNumber(viewport.height, `${path}.height`)
350
+ }
351
+
352
+ /**
353
+ * @param {AnyRecord} project
354
+ * @param {string} path
355
+ * @param {{ requireName?: boolean }} [options]
356
+ */
357
+ function assertBrowserProjectObject(project, path, options = {}) {
358
+ assertPlainObject(project, path)
359
+ assertKnownKeys(project, path, BROWSER_PROJECT_KEYS)
360
+
361
+ if (options.requireName) {
362
+ assertString(project.name, `${path}.name`)
363
+
364
+ if (!project.name.trim()) {
365
+ throwConfigError(`${path}.name`, 'must be a non-empty string.', {
366
+ value: project.name,
367
+ })
368
+ }
369
+ }
370
+
371
+ if (project.type !== undefined) {
372
+ assertOneOf(project.type, `${path}.type`, BROWSER_TYPES)
373
+ }
374
+
375
+ if (project.device !== undefined) {
376
+ assertString(project.device, `${path}.device`)
377
+ }
378
+
379
+ if (project.viewport !== undefined) {
380
+ assertBrowserProjectViewport(project.viewport, `${path}.viewport`)
381
+ }
382
+
383
+ if (project.contextOptions !== undefined) {
384
+ assertPlainObject(project.contextOptions, `${path}.contextOptions`)
385
+ }
386
+
387
+ if (project.launchOptions !== undefined) {
388
+ assertPlainObject(project.launchOptions, `${path}.launchOptions`)
389
+ }
390
+ }
391
+
392
+ /**
393
+ * @param {any} projects
394
+ * @returns {string[]}
395
+ */
396
+ function getBrowserProjectNames(projects) {
397
+ if (Array.isArray(projects)) {
398
+ return projects
399
+ .map((project) => (typeof project === 'string' ? project : project?.name))
400
+ .filter((name) => typeof name === 'string' && name.trim())
401
+ }
402
+
403
+ if (isPlainObject(projects)) {
404
+ return Object.keys(projects)
405
+ }
406
+
407
+ return []
408
+ }
409
+
410
+ /**
411
+ * @param {any} projects
412
+ * @param {string} path
413
+ */
414
+ function assertBrowserProjects(projects, path) {
415
+ if (Array.isArray(projects)) {
416
+ projects.forEach((entry, index) => {
417
+ if (typeof entry === 'string') {
418
+ assertString(entry, `${path}[${index}]`)
419
+ return
420
+ }
421
+
422
+ assertBrowserProjectObject(entry, `${path}[${index}]`, {
423
+ requireName: true,
424
+ })
425
+ })
426
+ return
427
+ }
428
+
429
+ assertPlainObject(projects, path)
430
+
431
+ for (const [name, project] of Object.entries(projects)) {
432
+ if (!name.trim()) {
433
+ throwConfigError(`${path}.${name}`, 'project names must be non-empty strings.', {
434
+ value: name,
435
+ })
436
+ }
437
+
438
+ assertBrowserProjectObject(project, `${path}.${name}`)
439
+ }
440
+ }
441
+
442
+ /**
443
+ * @param {any} value
444
+ * @param {string} path
445
+ */
446
+ function assertPositiveNumber(value, path) {
447
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
448
+ throwConfigError(path, `must be a positive number, received ${describeValue(value)}.`, {
449
+ value,
450
+ })
451
+ }
452
+ }
453
+
454
+ /**
455
+ * @param {SoundingConfig} config
456
+ * @returns {SoundingConfig}
457
+ */
458
+ function validateConfig(config) {
459
+ assertSection(config, 'sounding')
460
+ assertKnownKeys(config, 'sounding', TOP_LEVEL_KEYS)
461
+
462
+ assertStringArray(config.environments, 'sounding.environments')
463
+
464
+ assertSection(config.app, 'sounding.app')
465
+ assertKnownKeys(config.app, 'sounding.app', SECTION_KEYS.app)
466
+ assertString(config.app.path, 'sounding.app.path')
467
+ assertString(config.app.environment, 'sounding.app.environment')
468
+ assertBoolean(config.app.quiet, 'sounding.app.quiet')
469
+ assertPlainObject(config.app.liftOptions, 'sounding.app.liftOptions')
470
+
471
+ if (config.app.loadOptions !== undefined) {
472
+ assertPlainObject(config.app.loadOptions, 'sounding.app.loadOptions')
473
+ }
474
+
475
+ assertSection(config.world, 'sounding.world')
476
+ assertKnownKeys(config.world, 'sounding.world', SECTION_KEYS.world)
477
+ assertString(config.world.factories, 'sounding.world.factories')
478
+ assertString(config.world.scenarios, 'sounding.world.scenarios')
479
+
480
+ assertSection(config.datastore, 'sounding.datastore')
481
+ assertNoLegacyDatastoreConfig(config.datastore)
482
+ assertKnownKeys(config.datastore, 'sounding.datastore', SECTION_KEYS.datastore)
483
+ assertOneOf(config.datastore.mode, 'sounding.datastore.mode', DATASTORE_MODES)
484
+ assertString(config.datastore.identity, 'sounding.datastore.identity')
485
+
486
+ if (config.datastore.mode === 'managed') {
487
+ assertOneOf(config.datastore.adapter, 'sounding.datastore.adapter', ['sails-sqlite'])
488
+ assertString(config.datastore.root, 'sounding.datastore.root')
489
+ assertOneOf(config.datastore.isolation, 'sounding.datastore.isolation', DATASTORE_ISOLATION)
490
+ }
491
+
492
+ assertSection(config.browser, 'sounding.browser')
493
+ assertKnownKeys(config.browser, 'sounding.browser', SECTION_KEYS.browser)
494
+ assertBoolean(config.browser.enabled, 'sounding.browser.enabled')
495
+ assertOneOf(config.browser.type, 'sounding.browser.type', BROWSER_TYPES)
496
+ assertBrowserProjects(config.browser.projects, 'sounding.browser.projects')
497
+ assertString(config.browser.defaultProject, 'sounding.browser.defaultProject')
498
+
499
+ const browserProjectNames = getBrowserProjectNames(config.browser.projects)
500
+ if (!browserProjectNames.includes(config.browser.defaultProject)) {
501
+ throwConfigError(
502
+ 'sounding.browser.defaultProject',
503
+ `must reference one of the configured browser projects: ${browserProjectNames.map((project) => `\`${project}\``).join(', ')}.`,
504
+ {
505
+ value: config.browser.defaultProject,
506
+ allowed: browserProjectNames,
507
+ }
508
+ )
509
+ }
510
+
511
+ if (config.browser.baseUrl !== undefined) {
512
+ assertString(config.browser.baseUrl, 'sounding.browser.baseUrl')
513
+ }
514
+
515
+ assertPlainObject(config.browser.launchOptions, 'sounding.browser.launchOptions')
516
+
517
+ if (config.browser.artifacts !== undefined && typeof config.browser.artifacts !== 'boolean') {
518
+ assertPlainObject(config.browser.artifacts, 'sounding.browser.artifacts')
519
+ assertKnownKeys(
520
+ config.browser.artifacts,
521
+ 'sounding.browser.artifacts',
522
+ BROWSER_ARTIFACT_KEYS
523
+ )
524
+
525
+ if (config.browser.artifacts.outputDir !== undefined) {
526
+ assertString(config.browser.artifacts.outputDir, 'sounding.browser.artifacts.outputDir')
527
+ }
528
+
529
+ if (config.browser.artifacts.screenshot !== undefined) {
530
+ assertBrowserArtifactSetting(
531
+ config.browser.artifacts.screenshot,
532
+ 'sounding.browser.artifacts.screenshot'
533
+ )
534
+ }
535
+
536
+ if (config.browser.artifacts.trace !== undefined) {
537
+ assertBrowserArtifactSetting(
538
+ config.browser.artifacts.trace,
539
+ 'sounding.browser.artifacts.trace'
540
+ )
541
+ }
542
+
543
+ if (config.browser.artifacts.video !== undefined) {
544
+ assertBrowserArtifactSetting(
545
+ config.browser.artifacts.video,
546
+ 'sounding.browser.artifacts.video'
547
+ )
548
+ }
549
+
550
+ if (config.browser.artifacts.currentUrl !== undefined) {
551
+ assertBoolean(config.browser.artifacts.currentUrl, 'sounding.browser.artifacts.currentUrl')
552
+ }
553
+ }
554
+
555
+ assertSection(config.mail, 'sounding.mail')
556
+ assertKnownKeys(config.mail, 'sounding.mail', SECTION_KEYS.mail)
557
+ assertBoolean(config.mail.capture, 'sounding.mail.capture')
558
+
559
+ if (config.mail.layout !== false) {
560
+ assertString(config.mail.layout, 'sounding.mail.layout')
561
+ }
562
+
563
+ if (config.mail.deliver !== undefined) {
564
+ assertBoolean(config.mail.deliver, 'sounding.mail.deliver')
565
+ }
566
+
567
+ if (config.mail.mode !== undefined) {
568
+ assertOneOf(config.mail.mode, 'sounding.mail.mode', MAIL_MODES)
569
+ }
570
+
571
+ assertSection(config.request, 'sounding.request')
572
+ assertKnownKeys(config.request, 'sounding.request', SECTION_KEYS.request)
573
+ assertOneOf(
574
+ config.request.transport,
575
+ 'sounding.request.transport',
576
+ REQUEST_TRANSPORTS,
577
+ 'Use `virtual` for Sails-native in-process requests or `http` for real HTTP requests.'
578
+ )
579
+
580
+ if (config.request.baseUrl !== undefined) {
581
+ assertString(config.request.baseUrl, 'sounding.request.baseUrl')
582
+ }
583
+
584
+ assertSection(config.sockets, 'sounding.sockets')
585
+ assertKnownKeys(config.sockets, 'sounding.sockets', SECTION_KEYS.sockets)
586
+ assertBoolean(config.sockets.enabled, 'sounding.sockets.enabled')
587
+ assertPositiveNumber(config.sockets.timeout, 'sounding.sockets.timeout')
588
+ assertStringArray(config.sockets.transports, 'sounding.sockets.transports')
589
+ assertString(config.sockets.path, 'sounding.sockets.path')
590
+ assertPlainObject(config.sockets.headers, 'sounding.sockets.headers')
591
+ assertPlainObject(
592
+ config.sockets.initialConnectionHeaders,
593
+ 'sounding.sockets.initialConnectionHeaders'
594
+ )
595
+
596
+ if (config.sockets.baseUrl !== undefined) {
597
+ assertString(config.sockets.baseUrl, 'sounding.sockets.baseUrl')
598
+ }
599
+
600
+ assertSection(config.auth, 'sounding.auth')
601
+ assertKnownKeys(config.auth, 'sounding.auth', SECTION_KEYS.auth)
602
+ assertString(config.auth.defaultActor, 'sounding.auth.defaultActor')
603
+ assertNullableString(config.auth.modelIdentity, 'sounding.auth.modelIdentity')
604
+ assertNullableString(config.auth.sessionKey, 'sounding.auth.sessionKey')
605
+ assertNullableString(config.auth.worldCollection, 'sounding.auth.worldCollection')
606
+ assertSection(config.auth.password, 'sounding.auth.password')
607
+ assertKnownKeys(config.auth.password, 'sounding.auth.password', PASSWORD_KEYS)
608
+ assertString(config.auth.password.loginPath, 'sounding.auth.password.loginPath')
609
+ assertString(config.auth.password.pagePath, 'sounding.auth.password.pagePath')
610
+ assertPlainObject(config.auth.password.pageQuery, 'sounding.auth.password.pageQuery')
611
+ assertSection(config.auth.password.form, 'sounding.auth.password.form')
612
+ assertKnownKeys(config.auth.password.form, 'sounding.auth.password.form', PASSWORD_FORM_KEYS)
613
+ assertString(config.auth.password.form.email, 'sounding.auth.password.form.email')
614
+ assertString(config.auth.password.form.password, 'sounding.auth.password.form.password')
615
+ assertString(config.auth.password.form.rememberMe, 'sounding.auth.password.form.rememberMe')
616
+ assertString(config.auth.password.form.returnUrl, 'sounding.auth.password.form.returnUrl')
617
+ assertPlainObject(config.auth.password.selectors, 'sounding.auth.password.selectors')
618
+ assertKnownKeys(
619
+ config.auth.password.selectors,
620
+ 'sounding.auth.password.selectors',
621
+ PASSWORD_SELECTOR_KEYS
622
+ )
623
+
624
+ for (const [key, value] of Object.entries(config.auth.password.selectors)) {
625
+ assertString(value, `sounding.auth.password.selectors.${key}`)
626
+ }
627
+
628
+ return config
629
+ }
630
+
631
+ module.exports = {
632
+ validateConfig,
633
+ }