• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

VolvoxLLC / volvox-bot / 25144479621

30 Apr 2026 02:37AM UTC coverage: 90.135% (+0.1%) from 90.008%
25144479621

push

github

web-flow
feat(ai): add configurable moderation and summary model controls (#628)

9633 of 11308 branches covered (85.19%)

Branch coverage included in aggregate %.

305 of 312 new or added lines in 14 files covered. (97.76%)

7 existing lines in 4 files now uncovered.

15256 of 16305 relevant lines covered (93.57%)

178.43 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.35
/src/api/utils/configValidation.js
1
/**
2
 * Shared config validation utilities.
3
 *
4
 * Centralises CONFIG_SCHEMA, validateValue, and validateSingleValue so that
5
 * both route handlers and util modules can import from a single source of
6
 * truth without creating an inverted dependency (utils → routes).
7
 */
8

9
import {
10
  isSupportedAiModel,
11
  normalizeSupportedAiModel,
12
  SUPPORTED_AI_MODEL_TYPES,
13
} from '../../utils/supportedAiModels.js';
14
import { validateUrlForSsrfSync } from './ssrfProtection.js';
15

16
/** Module-level cache for compiled regex patterns used during validation. */
17
const _compiledPatterns = new Map();
55✔
18

19
/** Maximum number of distinct patterns to keep in the cache. */
20
const _MAX_PATTERN_CACHE = 100;
55✔
21

22
/**
23
 * Return a cached compiled RegExp for the given pattern string.
24
 * Avoids re-compiling the same pattern on every config validation call.
25
 * The cache is capped at _MAX_PATTERN_CACHE entries to prevent unbounded growth
26
 * in environments with dynamic schema patterns.
27
 *
28
 * @param {string} pattern
29
 * @returns {RegExp}
30
 */
31
function getCompiledPattern(pattern) {
32
  let re = _compiledPatterns.get(pattern);
13✔
33
  if (!re) {
13✔
34
    if (_compiledPatterns.size >= _MAX_PATTERN_CACHE) {
1!
35
      // Evict the oldest entry (Map preserves insertion order).
36
      _compiledPatterns.delete(_compiledPatterns.keys().next().value);
×
37
    }
38
    re = new RegExp(pattern);
1✔
39
    _compiledPatterns.set(pattern, re);
1✔
40
  }
41
  return re;
13✔
42
}
43

44
const XP_ACTION_TYPES = [
55✔
45
  'grantRole',
46
  'removeRole',
47
  'sendDm',
48
  'announce',
49
  'xpBonus',
50
  'addReaction',
51
  'nickPrefix',
52
  'nickSuffix',
53
  'webhook',
54
];
55

56
const XP_EMBED_FIELD_SCHEMA = {
55✔
57
  type: 'object',
58
  properties: {
59
    id: { type: 'string', nullable: true },
60
    name: { type: 'string', nullable: true },
61
    value: { type: 'string', nullable: true },
62
    inline: { type: 'boolean', nullable: true },
63
  },
64
};
65

66
const XP_EMBED_FOOTER_SCHEMA = {
55✔
67
  nullable: true,
68
  anyOf: [
69
    { type: 'string' },
70
    {
71
      type: 'object',
72
      properties: {
73
        text: { type: 'string', nullable: true },
74
        iconURL: { type: 'string', nullable: true },
75
      },
76
    },
77
  ],
78
};
79

80
const XP_EMBED_SCHEMA = {
55✔
81
  type: 'object',
82
  nullable: true,
83
  properties: {
84
    title: { type: 'string', nullable: true },
85
    description: { type: 'string', nullable: true },
86
    color: { type: 'string', nullable: true },
87
    thumbnail: { type: 'string', nullable: true },
88
    thumbnailType: {
89
      type: 'string',
90
      enum: ['none', 'user_avatar', 'server_icon', 'custom'],
91
      nullable: true,
92
    },
93
    thumbnailUrl: { type: 'string', nullable: true },
94
    fields: { type: 'array', items: XP_EMBED_FIELD_SCHEMA, nullable: true },
95
    footer: XP_EMBED_FOOTER_SCHEMA,
96
    footerText: { type: 'string', nullable: true },
97
    footerIconUrl: { type: 'string', nullable: true },
98
    image: { type: 'string', nullable: true },
99
    imageUrl: { type: 'string', nullable: true },
100
    timestamp: { type: 'boolean', nullable: true },
101
    showTimestamp: { type: 'boolean', nullable: true },
102
  },
103
};
104

105
const XP_ACTION_ITEM_SCHEMA = {
55✔
106
  type: 'object',
107
  required: ['type'],
108
  properties: {
109
    id: { type: 'string', nullable: true },
110
    type: {
111
      type: 'string',
112
      enum: XP_ACTION_TYPES,
113
    },
114
    roleId: { type: 'string', nullable: true },
115
    message: { type: 'string', nullable: true },
116
    template: { type: 'string', nullable: true },
117
    format: { type: 'string', enum: ['text', 'embed', 'both'], nullable: true },
118
    channelMode: {
119
      type: 'string',
120
      enum: ['current', 'specific', 'none'],
121
      nullable: true,
122
    },
123
    channelId: { type: 'string', nullable: true },
124
    emoji: { type: 'string', nullable: true },
125
    amount: { type: 'number', integer: true, min: 1, max: 1000000, nullable: true },
126
    prefix: { type: 'string', nullable: true },
127
    suffix: { type: 'string', nullable: true },
128
    url: { type: 'string', nullable: true, ssrfUrl: true, allowHttp: true },
129
    payload: { type: 'string', nullable: true },
130
    embed: XP_EMBED_SCHEMA,
131
  },
132
  openProperties: true,
133
};
134

135
const XP_ACTION_REQUIRED_FIELDS = {
55✔
136
  grantRole: ['roleId'],
137
  removeRole: ['roleId'],
138
  xpBonus: ['amount'],
139
  addReaction: ['emoji'],
140
  nickPrefix: ['prefix'],
141
  nickSuffix: ['suffix'],
142
  webhook: ['url'],
143
};
144

145
function validateXpActionRequiredFields(action, path) {
146
  const requiredFields = XP_ACTION_REQUIRED_FIELDS[action.type] ?? [];
29✔
147
  const errors = [];
29✔
148

149
  for (const field of requiredFields) {
29✔
150
    const value = action[field];
24✔
151
    if (value == null || value === '') {
24✔
152
      errors.push(`${path}.${field}: required for action type "${action.type}"`);
11✔
153
    }
154
  }
155

156
  return errors;
29✔
157
}
158

159
const XP_LEVEL_ACTION_ENTRY_SCHEMA = {
55✔
160
  type: 'object',
161
  required: ['level', 'actions'],
162
  properties: {
163
    id: { type: 'string', nullable: true },
164
    level: { type: 'number', integer: true, min: 1, max: 1000 },
165
    actions: {
166
      type: 'array',
167
      items: XP_ACTION_ITEM_SCHEMA,
168
    },
169
  },
170
};
171

172
const AI_AUTOMOD_CATEGORY_KEYS = [
55✔
173
  'toxicity',
174
  'spam',
175
  'harassment',
176
  'hateSpeech',
177
  'sexualContent',
178
  'violence',
179
  'selfHarm',
180
];
181

182
const AI_AUTOMOD_ACTION_TYPES = ['none', 'flag', 'delete', 'warn', 'timeout', 'kick', 'ban'];
55✔
183

184
const CHANNEL_MODE_TYPES = ['off', 'mention', 'vibe'];
55✔
185

186
const PERMISSION_LEVEL_TYPES = ['everyone', 'moderator', 'admin'];
55✔
187

188
const AI_AUTOMOD_ACTION_VALUE_SCHEMA = {
55✔
189
  anyOf: [
190
    { type: 'string', enum: AI_AUTOMOD_ACTION_TYPES },
191
    { type: 'array', items: { type: 'string', enum: AI_AUTOMOD_ACTION_TYPES } },
192
  ],
193
};
194

195
const AI_MODEL_VALUE_SCHEMA = { type: 'string', aiModel: true };
55✔
196

197
const AI_AUTOMOD_THRESHOLD_SCHEMA = {
55✔
198
  type: 'object',
199
  properties: Object.fromEntries(
200
    AI_AUTOMOD_CATEGORY_KEYS.map((category) => [category, { type: 'number', min: 0, max: 1 }]),
385✔
201
  ),
202
};
203

204
const AI_AUTOMOD_ACTION_SCHEMA = {
55✔
205
  type: 'object',
206
  properties: Object.fromEntries(
207
    AI_AUTOMOD_CATEGORY_KEYS.map((category) => [category, AI_AUTOMOD_ACTION_VALUE_SCHEMA]),
385✔
208
  ),
209
};
210

211
/**
212
 * Schema definitions for writable config sections.
213
 * Used to validate types before persisting changes.
214
 */
215
export const CONFIG_SCHEMA = {
55✔
216
  ai: {
217
    type: 'object',
218
    properties: {
219
      enabled: { type: 'boolean' },
220
      systemPrompt: { type: 'string', maxLength: 4000 },
221
      channels: { type: 'array' },
222
      blockedChannelIds: { type: 'array' },
223
      historyLength: { type: 'number', min: 1, max: 100 },
224
      historyTTLDays: { type: 'number', min: 1, max: 365 },
225
      threadMode: {
226
        type: 'object',
227
        properties: {
228
          enabled: { type: 'boolean' },
229
          autoArchiveMinutes: { type: 'number', min: 60, max: 10080 },
230
          reuseWindowMinutes: { type: 'number', min: 1, max: 1440 },
231
        },
232
      },
233
      channelModes: {
234
        type: 'object',
235
        openProperties: { type: 'string', enum: CHANNEL_MODE_TYPES },
236
      },
237
      defaultChannelMode: { type: 'string', enum: ['off', 'mention', 'vibe'] },
238
    },
239
  },
240
  welcome: {
241
    type: 'object',
242
    properties: {
243
      enabled: { type: 'boolean' },
244
      channelId: { type: 'string', nullable: true },
245
      message: { type: 'string' },
246
      returningMessage: { type: 'string', nullable: true },
247
      returningMessageEnabled: { type: 'boolean' },
248
      variants: {
249
        type: 'array',
250
        items: { type: 'string' },
251
      },
252
      channels: {
253
        type: 'array',
254
        items: {
255
          type: 'object',
256
          properties: {
257
            channelId: { type: 'string' },
258
            message: { type: 'string' },
259
            variants: { type: 'array', items: { type: 'string' } },
260
          },
261
          required: ['channelId'],
262
        },
263
      },
264
      dynamic: {
265
        type: 'object',
266
        properties: {
267
          enabled: { type: 'boolean' },
268
          timezone: { type: 'string' },
269
          activityWindowMinutes: { type: 'number', min: 1, max: 10080 },
270
          milestoneInterval: { type: 'number', min: 0, max: 10000 },
271
          highlightChannels: { type: 'array' },
272
          excludeChannels: { type: 'array' },
273
        },
274
      },
275
      rulesChannel: { type: 'string', nullable: true },
276
      verifiedRole: { type: 'string', nullable: true },
277
      introChannel: { type: 'string', nullable: true },
278
      roleMenu: {
279
        type: 'object',
280
        properties: {
281
          enabled: { type: 'boolean' },
282
          options: { type: 'array', items: { type: 'object', required: ['label', 'roleId'] } },
283
        },
284
      },
285
      dmSequence: {
286
        type: 'object',
287
        properties: {
288
          enabled: { type: 'boolean' },
289
          steps: { type: 'array', items: { type: 'string' } },
290
        },
291
      },
292
    },
293
  },
294
  spam: {
295
    type: 'object',
296
    properties: {
297
      enabled: { type: 'boolean' },
298
    },
299
  },
300
  moderation: {
301
    type: 'object',
302
    properties: {
303
      enabled: { type: 'boolean' },
304
      alertChannelId: { type: 'string', nullable: true },
305
      autoDelete: { type: 'boolean' },
306
      dmNotifications: {
307
        type: 'object',
308
        properties: {
309
          warn: { type: 'boolean' },
310
          timeout: { type: 'boolean' },
311
          kick: { type: 'boolean' },
312
          ban: { type: 'boolean' },
313
        },
314
      },
315
      escalation: {
316
        type: 'object',
317
        properties: {
318
          enabled: { type: 'boolean' },
319
          thresholds: { type: 'array' },
320
        },
321
      },
322
      logging: {
323
        type: 'object',
324
        properties: {
325
          channels: {
326
            type: 'object',
327
            properties: {
328
              default: { type: 'string', nullable: true },
329
              warns: { type: 'string', nullable: true },
330
              bans: { type: 'string', nullable: true },
331
              kicks: { type: 'string', nullable: true },
332
              timeouts: { type: 'string', nullable: true },
333
              purges: { type: 'string', nullable: true },
334
              locks: { type: 'string', nullable: true },
335
            },
336
          },
337
        },
338
      },
339
      protectRoles: {
340
        type: 'object',
341
        properties: {
342
          enabled: { type: 'boolean' },
343
          roleIds: { type: 'array', items: { type: 'string' } },
344
          includeAdmins: { type: 'boolean' },
345
          includeModerators: { type: 'boolean' },
346
          includeServerOwner: { type: 'boolean' },
347
        },
348
      },
349
      rateLimit: {
350
        type: 'object',
351
        properties: {
352
          enabled: { type: 'boolean' },
353
          maxMessages: { type: 'number', min: 1 },
354
          windowSeconds: { type: 'number', min: 1 },
355
          muteAfterTriggers: { type: 'number', min: 1 },
356
          muteWindowSeconds: { type: 'number', min: 1 },
357
          muteDurationSeconds: { type: 'number', min: 1 },
358
        },
359
      },
360
      linkFilter: {
361
        type: 'object',
362
        properties: {
363
          enabled: { type: 'boolean' },
364
          blockedDomains: { type: 'array', items: { type: 'string' } },
365
        },
366
      },
367
    },
368
  },
369
  triage: {
370
    type: 'object',
371
    properties: {
372
      enabled: { type: 'boolean' },
373
      defaultInterval: { type: 'number', min: 1, max: 3600 },
374
      maxBufferSize: { type: 'number', min: 1, max: 1000 },
375
      includeBotsInContext: { type: 'boolean' },
376
      botAllowlist: { type: 'array', items: { type: 'string' } },
377
      triggerWords: { type: 'array' },
378
      moderationKeywords: { type: 'array' },
379
      classifyModel: AI_MODEL_VALUE_SCHEMA,
380
      classifyBudget: { type: 'number', min: 0, max: 100000 },
381
      respondModel: AI_MODEL_VALUE_SCHEMA,
382
      respondBudget: { type: 'number', min: 0, max: 100000 },
383
      thinkingTokens: { type: 'number', min: 0, max: 100000 },
384
      classifyBaseUrl: { type: 'string', nullable: true },
385
      classifyApiKey: { type: 'string', nullable: true },
386
      respondBaseUrl: { type: 'string', nullable: true },
387
      respondApiKey: { type: 'string', nullable: true },
388
      contextMessages: { type: 'number', min: 0, max: 100 },
389
      timeout: { type: 'number', min: 1000, max: 300000 },
390
      moderationResponse: { type: 'boolean' },
391
      channels: { type: 'array' },
392
      excludeChannels: { type: 'array' },
393
      allowedRoles: { type: 'array', items: { type: 'string' } },
394
      excludedRoles: { type: 'array', items: { type: 'string' } },
395
      debugFooter: { type: 'boolean' },
396
      debugFooterLevel: { type: 'string', nullable: true },
397
      moderationLogChannel: { type: 'string', nullable: true },
398
      statusReactions: { type: 'boolean', nullable: true },
399
      dailyBudgetUsd: { type: 'number', min: 0, nullable: true },
400
      confidenceThreshold: { type: 'number', min: 0, max: 1, nullable: true },
401
      responseCooldownMs: { type: 'number', min: 0, nullable: true },
402
    },
403
  },
404
  aiAutoMod: {
405
    type: 'object',
406
    properties: {
407
      enabled: { type: 'boolean' },
408
      model: AI_MODEL_VALUE_SCHEMA,
409
      thresholds: AI_AUTOMOD_THRESHOLD_SCHEMA,
410
      actions: AI_AUTOMOD_ACTION_SCHEMA,
411
      timeoutDurationMs: { type: 'number', min: 1000, max: 2419200000 },
412
      flagChannelId: { type: 'string', nullable: true },
413
      autoDelete: { type: 'boolean' },
414
      exemptRoleIds: { type: 'array', items: { type: 'string' } },
415
    },
416
  },
417
  auditLog: {
418
    type: 'object',
419
    properties: {
420
      enabled: { type: 'boolean' },
421
      retentionDays: { type: 'number', min: 1, max: 365 },
422
    },
423
  },
424
  botStatus: {
425
    type: 'object',
426
    properties: {
427
      enabled: { type: 'boolean' },
428
      status: { type: 'string', enum: ['online', 'idle', 'dnd', 'invisible'] },
429
      activityType: {
430
        type: 'string',
431
        enum: ['Playing', 'Watching', 'Listening', 'Competing', 'Streaming', 'Custom'],
432
      },
433
      activities: { type: 'array', items: { type: 'string' } },
434
      rotateIntervalMs: { type: 'number' },
435
      rotation: {
436
        type: 'object',
437
        properties: {
438
          enabled: { type: 'boolean' },
439
          intervalMinutes: { type: 'number' },
440
          messages: {
441
            type: 'array',
442
            items: {
443
              type: 'object',
444
              properties: {
445
                type: {
446
                  type: 'string',
447
                  enum: ['Playing', 'Watching', 'Listening', 'Competing', 'Streaming', 'Custom'],
448
                },
449
                text: { type: 'string', minLength: 1, pattern: '\\S' },
450
              },
451
              required: ['text'],
452
            },
453
          },
454
        },
455
      },
456
    },
457
  },
458
  reminders: {
459
    type: 'object',
460
    properties: {
461
      enabled: { type: 'boolean' },
462
      maxPerUser: { type: 'number', min: 1, max: 100 },
463
    },
464
  },
465
  quietMode: {
466
    type: 'object',
467
    properties: {
468
      enabled: { type: 'boolean' },
469
      maxDurationMinutes: { type: 'number', min: 1, max: 10080 },
470
      allowedRoles: { type: 'array' },
471
    },
472
  },
473
  voice: {
474
    type: 'object',
475
    properties: {
476
      enabled: { type: 'boolean' },
477
      xpPerMinute: { type: 'number', min: 0, max: 1000 },
478
      dailyXpCap: { type: 'number', min: 0, max: 1000000 },
479
      logChannel: { type: 'string', nullable: true },
480
    },
481
  },
482
  permissions: {
483
    type: 'object',
484
    properties: {
485
      enabled: { type: 'boolean' },
486
      usePermissions: { type: 'boolean' },
487
      adminRoleIds: { type: 'array', items: { type: 'string' } },
488
      moderatorRoleIds: { type: 'array', items: { type: 'string' } },
489
      // Legacy singular fields — kept for backward compat during migration
490
      adminRoleId: { type: 'string', nullable: true },
491
      moderatorRoleId: { type: 'string', nullable: true },
492
      modRoles: { type: 'array', items: { type: 'string' } },
493
      // allowedCommands is a freeform map of command → permission level — no fixed property list
494
      allowedCommands: {
495
        type: 'object',
496
        openProperties: { type: 'string', enum: PERMISSION_LEVEL_TYPES },
497
      },
498
    },
499
  },
500
  tldr: {
501
    type: 'object',
502
    properties: {
503
      enabled: { type: 'boolean' },
504
      model: AI_MODEL_VALUE_SCHEMA,
505
      systemPrompt: { type: 'string', maxLength: 4000 },
506
      defaultMessages: { type: 'number', min: 1, max: 200 },
507
      maxMessages: { type: 'number', min: 1, max: 200 },
508
      cooldownSeconds: { type: 'number', min: 0, max: 3600 },
509
    },
510
  },
511
  xp: {
512
    type: 'object',
513
    properties: {
514
      enabled: { type: 'boolean' },
515
      levelThresholds: {
516
        type: 'array',
517
        items: { type: 'number', min: 0 },
518
      },
519
      levelActions: {
520
        type: 'array',
521
        items: XP_LEVEL_ACTION_ENTRY_SCHEMA,
522
        uniqueBy: 'level',
523
      },
524
      defaultActions: {
525
        type: 'array',
526
        items: XP_ACTION_ITEM_SCHEMA,
527
      },
528
      levelUpDm: {
529
        type: 'object',
530
        properties: {
531
          enabled: { type: 'boolean' },
532
          sendOnEveryLevel: { type: 'boolean' },
533
          defaultMessage: { type: 'string', minLength: 1, maxLength: 2000, pattern: '\\S' },
534
          messages: {
535
            type: 'array',
536
            uniqueBy: 'level',
537
            items: {
538
              type: 'object',
539
              required: ['level', 'message'],
540
              properties: {
541
                level: { type: 'number', integer: true, min: 1, max: 1000 },
542
                message: { type: 'string', minLength: 1, maxLength: 2000, pattern: '\\S' },
543
              },
544
            },
545
          },
546
        },
547
      },
548
      roleRewards: {
549
        type: 'object',
550
        properties: {
551
          stackRoles: { type: 'boolean' },
552
          removeOnLevelDown: { type: 'boolean' },
553
        },
554
      },
555
    },
556
  },
557
};
558

559
/**
560
 * Validate a value against a schema fragment and collect any validation errors.
561
 *
562
 * @param {*} value - The value to validate.
563
 * @param {Object} schema - Schema fragment describing the expected shape; may include `type` (boolean|string|number|array|object), `nullable`, and `properties` for object children.
564
 * @param {string} path - Dot-notation path used to prefix validation error messages.
565
 * @returns {string[]} Array of validation error messages; empty if the value is valid for the provided schema.
566
 */
567
export function validateValue(value, schema, path) {
568
  const errors = [];
480✔
569

570
  if (value === undefined) {
480!
UNCOV
571
    return errors;
×
572
  }
573

574
  if (value === null && schema.nullable) {
480✔
575
    return errors;
8✔
576
  }
577

578
  if (schema.anyOf) {
472✔
579
    const results = schema.anyOf.map((candidate) => validateValue(value, candidate, path));
24✔
580
    const success = results.find((candidateErrors) => candidateErrors.length === 0);
21✔
581
    if (success) {
12✔
582
      return success;
8✔
583
    }
584
    return results.flat();
4✔
585
  }
586

587
  if (value === null) {
460✔
588
    errors.push(`${path}: must not be null`);
7✔
589
    return errors;
7✔
590
  }
591

592
  switch (schema.type) {
453✔
593
    case 'boolean':
594
      if (typeof value !== 'boolean') {
57✔
595
        errors.push(`${path}: expected boolean, got ${typeof value}`);
12✔
596
      }
597
      break;
57✔
598
    case 'string':
599
      if (typeof value !== 'string') {
148✔
600
        errors.push(`${path}: expected string, got ${typeof value}`);
8✔
601
      } else {
602
        if (typeof schema.minLength === 'number' && value.length < schema.minLength) {
140✔
603
          errors.push(`${path}: must be at least ${schema.minLength} characters`);
3✔
604
        }
605
        if (schema.aiModel) {
140✔
606
          if (!isSupportedAiModel(value)) {
13✔
607
            errors.push(
5✔
608
              `${path}: must be one of [${SUPPORTED_AI_MODEL_TYPES.join(', ')}], got "${value}"`,
609
            );
610
          }
611
        } else if (schema.enum && !schema.enum.includes(value)) {
127✔
612
          errors.push(`${path}: must be one of [${schema.enum.join(', ')}], got "${value}"`);
9✔
613
        }
614
        if (schema.maxLength != null && value.length > schema.maxLength) {
140✔
615
          errors.push(`${path}: exceeds max length of ${schema.maxLength}`);
3✔
616
        }
617
        if (schema.pattern && !getCompiledPattern(schema.pattern).test(value)) {
140✔
618
          errors.push(`${path}: does not match required pattern`);
4✔
619
        }
620
        if (schema.ssrfUrl) {
140✔
621
          const ssrfResult = validateUrlForSsrfSync(value, {
4✔
622
            allowHttp: schema.allowHttp === true,
623
          });
624
          if (!ssrfResult.valid) {
4✔
625
            errors.push(`${path}: ${ssrfResult.error}`);
2✔
626
          }
627
        }
628
      }
629
      break;
148✔
630
    case 'number':
631
      if (typeof value !== 'number' || !Number.isFinite(value)) {
66✔
632
        errors.push(`${path}: expected finite number, got ${typeof value}`);
6✔
633
      } else {
634
        if (schema.min != null && value < schema.min) {
60✔
635
          errors.push(`${path}: must be >= ${schema.min}`);
9✔
636
        }
637
        if (schema.max != null && value > schema.max) {
60✔
638
          errors.push(`${path}: must be <= ${schema.max}`);
7✔
639
        }
640
        if (schema.integer === true && !Number.isInteger(value)) {
60✔
641
          errors.push(`${path}: must be an integer`);
3✔
642
        }
643
      }
644
      break;
66✔
645
    case 'array':
646
      if (!Array.isArray(value)) {
72✔
647
        errors.push(`${path}: expected array, got ${typeof value}`);
8✔
648
      } else if (schema.items) {
64✔
649
        for (let i = 0; i < value.length; i++) {
62✔
650
          errors.push(...validateValue(value[i], schema.items, `${path}[${i}]`));
76✔
651
        }
652

653
        if (schema.uniqueBy) {
62✔
654
          const seen = new Map();
19✔
655
          for (let i = 0; i < value.length; i++) {
19✔
656
            const item = value[i];
22✔
657
            const uniqueValue =
658
              item && typeof item === 'object' && !Array.isArray(item)
22!
659
                ? item[schema.uniqueBy]
660
                : undefined;
661
            if (uniqueValue === undefined) continue;
22✔
662
            if (seen.has(uniqueValue)) {
21✔
663
              errors.push(
2✔
664
                `${path}[${i}].${schema.uniqueBy}: duplicate value "${uniqueValue}" also used at index ${seen.get(uniqueValue)}`,
665
              );
666
            } else {
667
              seen.set(uniqueValue, i);
19✔
668
            }
669
          }
670
        }
671
      }
672
      break;
72✔
673
    case 'object':
674
      if (typeof value !== 'object' || Array.isArray(value)) {
110✔
675
        errors.push(
1✔
676
          `${path}: expected object, got ${Array.isArray(value) ? 'array' : typeof value}`,
1!
677
        );
678
      } else {
679
        if (schema.required) {
109✔
680
          for (const key of schema.required) {
61✔
681
            if (!Object.hasOwn(value, key)) {
84✔
682
              errors.push(`${path}: missing required key "${key}"`);
6✔
683
            }
684
          }
685
        }
686

687
        if (schema.properties || schema.openProperties) {
109✔
688
          const properties = schema.properties ?? {};
108✔
689
          for (const [key, val] of Object.entries(value)) {
108✔
690
            if (Object.hasOwn(properties, key)) {
190✔
691
              errors.push(...validateValue(val, properties[key], `${path}.${key}`));
177✔
692
            } else if (schema.openProperties && schema.openProperties !== true) {
13✔
693
              errors.push(...validateValue(val, schema.openProperties, `${path}.${key}`));
2✔
694
            } else if (!schema.openProperties) {
11✔
695
              errors.push(`${path}.${key}: unknown config key`);
8✔
696
            }
697
            // openProperties: true - freeform map, unknown keys are allowed
698
          }
699
        }
700

701
        if (schema === XP_ACTION_ITEM_SCHEMA) {
109✔
702
          errors.push(...validateXpActionRequiredFields(value, path));
29✔
703
        }
704
      }
705
      break;
110✔
706
  }
707

708
  return errors;
453✔
709
}
710

711
/**
712
 * Validate a single configuration path and its value against the writable config schema.
713
 *
714
 * @param {string} path - Dot-notation config path (e.g. "ai.enabled").
715
 * @param {*} value - The value to validate for the given path.
716
 * @returns {string[]} Array of validation error messages (empty if valid).
717
 */
718
function resolveSchemaForPath(path) {
719
  const segments = path.split('.');
209✔
720
  const section = segments[0];
209✔
721

722
  const schema = CONFIG_SCHEMA[section];
209✔
723
  if (!schema) {
209✔
724
    // Unknown section — let SAFE_CONFIG_KEYS guard handle it.
725
    return { status: 'unknown-section' };
2✔
726
  }
727

728
  // Walk the schema tree to find the leaf schema for this path.
729
  let currentSchema = schema;
207✔
730
  for (let i = 1; i < segments.length; i++) {
207✔
731
    if (currentSchema.properties && Object.hasOwn(currentSchema.properties, segments[i])) {
245✔
732
      currentSchema = currentSchema.properties[segments[i]];
234✔
733
    } else if (currentSchema.openProperties) {
11✔
734
      // Dynamic map keys (e.g. channelModes.<channelId>) consume this path
735
      // segment, then the remaining path (if any) resolves against the map's
736
      // value schema. `openProperties: true` means the dynamic value is fully
737
      // freeform and can only be validated as "allowed".
738
      currentSchema = currentSchema.openProperties === true ? {} : currentSchema.openProperties;
5!
739
    } else {
740
      return { status: 'unknown-path' };
6✔
741
    }
742
  }
743

744
  return { status: 'found', schema: currentSchema };
201✔
745
}
746

747
/**
748
 * Validate a single configuration path and its value against the writable config schema.
749
 *
750
 * @param {string} path - Dot-notation config path (e.g. "ai.enabled").
751
 * @param {*} value - The value to validate for the given path.
752
 * @returns {string[]} Array of validation error messages (empty if valid).
753
 */
754
export function validateSingleValue(path, value) {
755
  const resolved = resolveSchemaForPath(path);
157✔
756
  if (resolved.status === 'unknown-path') return [`Unknown config path: ${path}`];
157✔
757
  if (resolved.status === 'unknown-section') return [];
151✔
758

759
  return validateValue(value, resolved.schema, path);
149✔
760
}
761

762
/**
763
 * Return the canonical runtime value for config leaves that support legacy aliases
764
 * or case-insensitive inputs. Unknown paths and ordinary values pass through.
765
 *
766
 * @param {string} path
767
 * @param {*} value
768
 * @returns {*}
769
 */
770
export function normalizeSingleValue(path, value) {
771
  const resolved = resolveSchemaForPath(path);
52✔
772
  if (resolved.schema?.aiModel && isSupportedAiModel(value)) {
52✔
773
    return normalizeSupportedAiModel(value);
3✔
774
  }
775
  return value;
49✔
776
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc