lumisjs 1.0.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/LICENSE +21 -0
- package/README.md +274 -0
- package/SECURITY.md +160 -0
- package/index.js +90 -0
- package/package.json +68 -0
- package/src/cache/CacheManager.js +393 -0
- package/src/cache/MemoryAdapter.js +259 -0
- package/src/cache/MultiLevelCache.js +362 -0
- package/src/cache/RedisAdapter.js +329 -0
- package/src/cache/SQLiteAdapter.js +280 -0
- package/src/cache/index.js +15 -0
- package/src/client/Client.js +554 -0
- package/src/client/ShardingManager.js +274 -0
- package/src/config/ConfigValidator.js +172 -0
- package/src/config/index.js +7 -0
- package/src/dashboard/DashboardManager.js +362 -0
- package/src/datagen/DataGenerator.js +47 -0
- package/src/datagen/apis/GraphqlMocker.js +16 -0
- package/src/datagen/apis/OpenApiMocker.js +18 -0
- package/src/datagen/cli.js +130 -0
- package/src/datagen/formatters/csv.js +45 -0
- package/src/datagen/formatters/index.js +15 -0
- package/src/datagen/formatters/json.js +33 -0
- package/src/datagen/formatters/mongo.js +22 -0
- package/src/datagen/formatters/sql.js +32 -0
- package/src/datagen/index.js +17 -0
- package/src/datagen/plugin/PluginManager.js +43 -0
- package/src/datagen/plugins/example-plugin.js +21 -0
- package/src/datagen/schema/SchemaGenerator.js +172 -0
- package/src/datagen/schema/loadSchema.js +14 -0
- package/src/datagen/utils/seed.js +26 -0
- package/src/di/ServiceContainer.js +229 -0
- package/src/errors/ErrorCodes.js +110 -0
- package/src/errors/LumisError.js +85 -0
- package/src/game/EconomyManager.js +161 -0
- package/src/game/GameSessionManager.js +76 -0
- package/src/game/GuildManager.js +197 -0
- package/src/game/InventoryManager.js +140 -0
- package/src/game/LevelingSystem.js +194 -0
- package/src/game/MusicManager.js +587 -0
- package/src/health/HealthChecker.js +170 -0
- package/src/health/index.js +7 -0
- package/src/performance/PerformanceMonitor.js +244 -0
- package/src/performance/index.js +7 -0
- package/src/security/InputSanitizer.js +185 -0
- package/src/security/SecurityMiddleware.js +231 -0
- package/src/security/index.js +9 -0
- package/src/shutdown/GracefulShutdown.js +159 -0
- package/src/shutdown/index.js +7 -0
- package/src/utils/StructuredLogger.js +118 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { EventEmitter } = require('node:events');
|
|
4
|
+
const { fork } = require('node:child_process');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const Logger = require('../utils/Logger');
|
|
7
|
+
const Collection = require('../utils/Collection');
|
|
8
|
+
|
|
9
|
+
class ShardingManager extends EventEmitter {
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} file - Path to the bot's main file
|
|
12
|
+
* @param {object} [options]
|
|
13
|
+
* @param {string} [options.token] - Bot token (used to fetch shard count)
|
|
14
|
+
* @param {number|'auto'} [options.totalShards='auto']
|
|
15
|
+
* @param {string[]} [options.execArgv]
|
|
16
|
+
* @param {number} [options.spawnDelay=5500] - ms delay between shard spawns
|
|
17
|
+
* @param {number} [options.respawnDelay=5000] - ms before crashed shard respawns
|
|
18
|
+
* @param {number} [options.maxRespawnAttempts=5]
|
|
19
|
+
*/
|
|
20
|
+
constructor(file, options = {}) {
|
|
21
|
+
super();
|
|
22
|
+
|
|
23
|
+
if (!file || typeof file !== 'string') {
|
|
24
|
+
throw new TypeError('ShardingManager requires a valid file path.');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
this.file = path.resolve(file);
|
|
28
|
+
this.token = options.token ? options.token.replace(/^Bot\s*/i, '') : null;
|
|
29
|
+
this.totalShards = options.totalShards ?? 'auto';
|
|
30
|
+
this.execArgv = options.execArgv ?? [];
|
|
31
|
+
this.autoFetchShards = options.autoFetchShards ?? true;
|
|
32
|
+
this.spawnDelay = options.spawnDelay ?? 5500;
|
|
33
|
+
this.respawnDelay = options.respawnDelay ?? 5000;
|
|
34
|
+
this.maxRespawnAttempts = options.maxRespawnAttempts ?? 5;
|
|
35
|
+
|
|
36
|
+
/** @type {Collection<number, import('node:child_process').ChildProcess>} */
|
|
37
|
+
this.shards = new Collection();
|
|
38
|
+
|
|
39
|
+
this.logger = new Logger({ prefix: 'ShardingManager', level: 'info' });
|
|
40
|
+
|
|
41
|
+
/** @type {Map<string, { resolve: Function, reject: Function, timer: NodeJS.Timeout }>} */
|
|
42
|
+
this._promises = new Map();
|
|
43
|
+
this._nonce = 0;
|
|
44
|
+
|
|
45
|
+
/** @type {Map<number, number>} */
|
|
46
|
+
this._respawnCounts = new Map();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Fetch the recommended shard count from Discord.
|
|
51
|
+
* @returns {Promise<number>}
|
|
52
|
+
*/
|
|
53
|
+
async fetchRecommendedShards() {
|
|
54
|
+
if (!this.token) {
|
|
55
|
+
throw new Error('A token is required to fetch the recommended shard count.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const { fetch } = require('undici');
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
63
|
+
|
|
64
|
+
const res = await fetch('https://discord.com/api/v10/gateway/bot', {
|
|
65
|
+
headers: { Authorization: `Bot ${this.token}` },
|
|
66
|
+
signal: controller.signal,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
clearTimeout(timeout);
|
|
70
|
+
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
throw new Error(`Failed to fetch gateway info: ${res.status} ${res.statusText}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const data = await res.json();
|
|
76
|
+
|
|
77
|
+
if (!data || typeof data.shards !== 'number' || data.shards < 1) {
|
|
78
|
+
throw new Error('Invalid shard data received from gateway.');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return data.shards;
|
|
82
|
+
} catch (err) {
|
|
83
|
+
// Provide actionable message while keeping original error available
|
|
84
|
+
throw new Error(`Could not determine recommended shard count: ${err.message}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Spawn all shards sequentially.
|
|
90
|
+
* @returns {Promise<void>}
|
|
91
|
+
*/
|
|
92
|
+
async spawn() {
|
|
93
|
+
if (this.totalShards === 'auto') {
|
|
94
|
+
if (!this.autoFetchShards) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
"totalShards is set to 'auto' but autoFetchShards is disabled. Provide totalShards explicitly."
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this.logger.info('Fetching recommended shard count from Discord...');
|
|
101
|
+
this.totalShards = await this.fetchRecommendedShards();
|
|
102
|
+
this.logger.info(`Recommended shard count: ${this.totalShards}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (typeof this.totalShards !== 'number' || this.totalShards < 1) {
|
|
106
|
+
throw new RangeError('totalShards must be a positive integer.');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.logger.info(`Spawning ${this.totalShards} shard(s)...`);
|
|
110
|
+
|
|
111
|
+
for (let i = 0; i < this.totalShards; i++) {
|
|
112
|
+
this._spawnShard(i);
|
|
113
|
+
if (i < this.totalShards - 1) {
|
|
114
|
+
// Discord requires a delay between shard connections to avoid rate limits
|
|
115
|
+
await new Promise((r) => setTimeout(r, this.spawnDelay));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Spawn a single shard by ID.
|
|
122
|
+
* @param {number} id
|
|
123
|
+
*/
|
|
124
|
+
_spawnShard(id) {
|
|
125
|
+
this.logger.info(`[Shard ${id}] Spawning...`);
|
|
126
|
+
|
|
127
|
+
const env = {
|
|
128
|
+
...process.env,
|
|
129
|
+
SHARD_ID: String(id),
|
|
130
|
+
TOTAL_SHARDS: String(this.totalShards),
|
|
131
|
+
...(this.token ? { DISCORD_TOKEN: this.token } : {}),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const child = fork(this.file, [], {
|
|
135
|
+
env,
|
|
136
|
+
execArgv: this.execArgv,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
this.shards.set(id, child);
|
|
140
|
+
|
|
141
|
+
child.on('message', (message) => this._handleMessage(child, message, id));
|
|
142
|
+
|
|
143
|
+
child.on('exit', (code, signal) => {
|
|
144
|
+
this.shards.delete(id);
|
|
145
|
+
|
|
146
|
+
const attempts = this._respawnCounts.get(id) ?? 0;
|
|
147
|
+
|
|
148
|
+
if (attempts >= this.maxRespawnAttempts) {
|
|
149
|
+
this.logger.error(
|
|
150
|
+
`[Shard ${id}] Reached max respawn attempts (${this.maxRespawnAttempts}). Giving up.`
|
|
151
|
+
);
|
|
152
|
+
this.emit('shardDeath', id);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this.logger.warn(
|
|
157
|
+
`[Shard ${id}] Exited (code=${code ?? 'null'}, signal=${signal ?? 'null'}). ` +
|
|
158
|
+
`Respawning in ${this.respawnDelay}ms (${attempts + 1}/${this.maxRespawnAttempts})...`
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
this._respawnCounts.set(id, attempts + 1);
|
|
162
|
+
|
|
163
|
+
setTimeout(() => {
|
|
164
|
+
this._spawnShard(id);
|
|
165
|
+
}, this.respawnDelay).unref();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
child.on('error', (err) => {
|
|
169
|
+
this.logger.error(`[Shard ${id}] Process error: ${err.message}`);
|
|
170
|
+
this.emit('shardError', err, id);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Reset respawn counter once it stays alive for >30s
|
|
174
|
+
const stableTimer = setTimeout(() => {
|
|
175
|
+
this._respawnCounts.delete(id);
|
|
176
|
+
}, 30_000).unref();
|
|
177
|
+
|
|
178
|
+
this.emit('shardCreate', child, id);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Broadcast a named action to all shards. Shards must register handlers
|
|
183
|
+
* via `client.registerBroadcastHandler(name, fn)` to respond to actions.
|
|
184
|
+
* @param {string} action
|
|
185
|
+
* @param {Array<any>} [args]
|
|
186
|
+
* @param {object} [options]
|
|
187
|
+
*/
|
|
188
|
+
async broadcastAction(action, args = [], options = {}) {
|
|
189
|
+
if (typeof action !== 'string') throw new TypeError('action must be a string');
|
|
190
|
+
|
|
191
|
+
const timeout = options.timeout ?? 10_000;
|
|
192
|
+
const nonce = `${Date.now()}_${++this._nonce}`;
|
|
193
|
+
const promises = [];
|
|
194
|
+
|
|
195
|
+
for (const [id, shard] of this.shards) {
|
|
196
|
+
promises.push(
|
|
197
|
+
new Promise((resolve, reject) => {
|
|
198
|
+
const key = `${nonce}_${id}`;
|
|
199
|
+
|
|
200
|
+
const timer = setTimeout(() => {
|
|
201
|
+
this._promises.delete(key);
|
|
202
|
+
reject(new Error(`Shard ${id} action timed out after ${timeout}ms`));
|
|
203
|
+
}, timeout);
|
|
204
|
+
|
|
205
|
+
this._promises.set(key, { resolve, reject, timer });
|
|
206
|
+
|
|
207
|
+
shard.send({
|
|
208
|
+
_type: 'BROADCAST_ACTION',
|
|
209
|
+
_nonce: nonce,
|
|
210
|
+
action,
|
|
211
|
+
args,
|
|
212
|
+
});
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return Promise.all(promises);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Send a message to a specific shard.
|
|
222
|
+
* @param {number} shardId
|
|
223
|
+
* @param {any} message
|
|
224
|
+
*/
|
|
225
|
+
sendTo(shardId, message) {
|
|
226
|
+
const shard = this.shards.get(shardId);
|
|
227
|
+
if (!shard) throw new Error(`Shard ${shardId} is not running.`);
|
|
228
|
+
shard.send(message);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** @private */
|
|
232
|
+
_handleMessage(shard, message, shardId) {
|
|
233
|
+
if (!message || typeof message !== 'object') return;
|
|
234
|
+
|
|
235
|
+
if (message._type === 'ACTION_RESULT') {
|
|
236
|
+
const key = `${message._nonce}_${message._shardId}`;
|
|
237
|
+
const entry = this._promises.get(key);
|
|
238
|
+
if (!entry) return;
|
|
239
|
+
|
|
240
|
+
clearTimeout(entry.timer);
|
|
241
|
+
this._promises.delete(key);
|
|
242
|
+
|
|
243
|
+
if (message._error) {
|
|
244
|
+
entry.reject(new Error(message._error));
|
|
245
|
+
} else {
|
|
246
|
+
entry.resolve(message._result);
|
|
247
|
+
}
|
|
248
|
+
} else {
|
|
249
|
+
this.emit('message', shard, message, shardId);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Kill all shards and clean up.
|
|
255
|
+
*/
|
|
256
|
+
destroy() {
|
|
257
|
+
for (const [id, shard] of this.shards) {
|
|
258
|
+
this.logger.info(`[Shard ${id}] Killing...`);
|
|
259
|
+
shard.removeAllListeners();
|
|
260
|
+
shard.kill();
|
|
261
|
+
}
|
|
262
|
+
this.shards.clear();
|
|
263
|
+
this._respawnCounts.clear();
|
|
264
|
+
|
|
265
|
+
// Reject pending evals
|
|
266
|
+
for (const [key, entry] of this._promises) {
|
|
267
|
+
clearTimeout(entry.timer);
|
|
268
|
+
entry.reject(new Error('ShardingManager was destroyed.'));
|
|
269
|
+
}
|
|
270
|
+
this._promises.clear();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
module.exports = ShardingManager;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Logger = require('../utils/Logger');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Configuration Validator
|
|
7
|
+
* Validates configuration objects against schemas.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
class ConfigValidator {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.logger = new Logger({ prefix: 'ConfigValidator', level: 'info' });
|
|
13
|
+
this.schemas = new Map();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Register a configuration schema.
|
|
18
|
+
* @param {string} name
|
|
19
|
+
* @param {object} schema - Schema definition
|
|
20
|
+
*/
|
|
21
|
+
registerSchema(name, schema) {
|
|
22
|
+
this.schemas.set(name, schema);
|
|
23
|
+
this.logger.info(`Registered schema: ${name}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validate configuration against a schema.
|
|
28
|
+
* @param {string} schemaName
|
|
29
|
+
* @param {object} config
|
|
30
|
+
* @returns {object} { valid: boolean, errors: string[] }
|
|
31
|
+
*/
|
|
32
|
+
validate(schemaName, config) {
|
|
33
|
+
const schema = this.schemas.get(schemaName);
|
|
34
|
+
if (!schema) {
|
|
35
|
+
throw new Error(`Schema not found: ${schemaName}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const errors = [];
|
|
39
|
+
const warnings = [];
|
|
40
|
+
|
|
41
|
+
this._validateObject(config, schema, '', errors, warnings);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
valid: errors.length === 0,
|
|
45
|
+
errors,
|
|
46
|
+
warnings,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Validate an object against a schema.
|
|
52
|
+
* @private
|
|
53
|
+
*/
|
|
54
|
+
_validateObject(obj, schema, path, errors, warnings) {
|
|
55
|
+
// Check required fields
|
|
56
|
+
if (schema.required) {
|
|
57
|
+
for (const field of schema.required) {
|
|
58
|
+
if (!(field in obj)) {
|
|
59
|
+
errors.push(`${path}${field}: Required field is missing`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check each property
|
|
65
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
66
|
+
const fieldSchema = schema.properties?.[key];
|
|
67
|
+
if (!fieldSchema) {
|
|
68
|
+
warnings.push(`${path}${key}: Unknown field`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const fieldPath = path ? `${path}.${key}` : key;
|
|
73
|
+
this._validateValue(value, fieldSchema, fieldPath, errors, warnings);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Validate a value against a field schema.
|
|
79
|
+
* @private
|
|
80
|
+
*/
|
|
81
|
+
_validateValue(value, schema, path, errors, warnings) {
|
|
82
|
+
// Check type
|
|
83
|
+
if (schema.type && typeof value !== schema.type) {
|
|
84
|
+
errors.push(`${path}: Expected type '${schema.type}', got '${typeof value}'`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Check enum
|
|
89
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
90
|
+
errors.push(`${path}: Must be one of ${schema.enum.join(', ')}`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Check range for numbers
|
|
95
|
+
if (typeof value === 'number') {
|
|
96
|
+
if (schema.min !== undefined && value < schema.min) {
|
|
97
|
+
errors.push(`${path}: Must be >= ${schema.min}`);
|
|
98
|
+
}
|
|
99
|
+
if (schema.max !== undefined && value > schema.max) {
|
|
100
|
+
errors.push(`${path}: Must be <= ${schema.max}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Check length for strings
|
|
105
|
+
if (typeof value === 'string') {
|
|
106
|
+
if (schema.minLength && value.length < schema.minLength) {
|
|
107
|
+
errors.push(`${path}: Must be at least ${schema.minLength} characters`);
|
|
108
|
+
}
|
|
109
|
+
if (schema.maxLength && value.length > schema.maxLength) {
|
|
110
|
+
errors.push(`${path}: Must be at most ${schema.maxLength} characters`);
|
|
111
|
+
}
|
|
112
|
+
if (schema.pattern && !new RegExp(schema.pattern).test(value)) {
|
|
113
|
+
errors.push(`${path}: Does not match required pattern`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Check array
|
|
118
|
+
if (Array.isArray(value)) {
|
|
119
|
+
if (schema.minItems && value.length < schema.minItems) {
|
|
120
|
+
errors.push(`${path}: Must have at least ${schema.minItems} items`);
|
|
121
|
+
}
|
|
122
|
+
if (schema.maxItems && value.length > schema.maxItems) {
|
|
123
|
+
errors.push(`${path}: Must have at most ${schema.maxItems} items`);
|
|
124
|
+
}
|
|
125
|
+
if (schema.items) {
|
|
126
|
+
value.forEach((item, index) => {
|
|
127
|
+
this._validateValue(item, schema.items, `${path}[${index}]`, errors, warnings);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Check nested object
|
|
133
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
134
|
+
if (schema.properties) {
|
|
135
|
+
this._validateObject(value, schema, path, errors, warnings);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Custom validator
|
|
140
|
+
if (schema.validator && typeof schema.validator === 'function') {
|
|
141
|
+
const result = schema.validator(value);
|
|
142
|
+
if (result !== true) {
|
|
143
|
+
errors.push(`${path}: ${result || 'Custom validation failed'}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Get all registered schemas.
|
|
150
|
+
* @returns {Array}
|
|
151
|
+
*/
|
|
152
|
+
getSchemas() {
|
|
153
|
+
return Array.from(this.schemas.keys());
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Remove a schema.
|
|
158
|
+
* @param {string} name
|
|
159
|
+
*/
|
|
160
|
+
removeSchema(name) {
|
|
161
|
+
this.schemas.delete(name);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Clear all schemas.
|
|
166
|
+
*/
|
|
167
|
+
clear() {
|
|
168
|
+
this.schemas.clear();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = ConfigValidator;
|