@ai_mind/dsh-a2a 0.0.0-stage → 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/lib/config.js ADDED
@@ -0,0 +1,565 @@
1
+ /**
2
+ * A2A server configuration: defaults, normalization and validation.
3
+ *
4
+ * The loader passes the row's `config` as plain JSON (YAML scalars already
5
+ * resolved), so validation happens here at activation time and fails loudly
6
+ * with the offending path. `normalizeConfig` returns a deep-frozen object the
7
+ * rest of the plugin can treat as immutable.
8
+ *
9
+ * @module @ai_mind/dsh-a2a/config
10
+ */
11
+
12
+ import { isPlainObject, deepMerge, deepFreeze } from './util.js'
13
+
14
+ /** Error raised for an invalid plugin configuration. */
15
+ export class ConfigError extends Error {
16
+ constructor(message) {
17
+ super(message)
18
+ this.name = 'A2AConfigError'
19
+ }
20
+ }
21
+
22
+ /** Paths whose prefix already belongs to the Harness Web GUI. */
23
+ const RESERVED_MOUNT_PREFIXES = ['/api', '/plugins', '/.well-known']
24
+
25
+ /** Default Agent Card describing what a Harness agent can do. */
26
+ const DEFAULT_SKILLS = [
27
+ {
28
+ id: 'general',
29
+ name: 'General-purpose coding assistant',
30
+ description:
31
+ 'Answers questions, writes and edits code, runs commands, and completes multi-step tasks in the session workspace.',
32
+ tags: ['general', 'coding', 'analysis', 'files', 'automation'],
33
+ examples: [
34
+ 'Summarize the repository in this workspace.',
35
+ 'Fix the failing test and explain the root cause.',
36
+ ],
37
+ },
38
+ ]
39
+
40
+ /** Complete default configuration. */
41
+ export const DEFAULT_CONFIG = {
42
+ enabled: true,
43
+ server: {
44
+ mountPath: '/a2a',
45
+ cardPath: '/.well-known/agent-card.json',
46
+ publicUrl: null,
47
+ trustProxy: false,
48
+ streaming: true,
49
+ pushNotifications: true,
50
+ extendedCard: false,
51
+ cardCacheSeconds: 300,
52
+ health: true,
53
+ logRequests: false,
54
+ cors: {
55
+ enabled: false,
56
+ origins: [],
57
+ allowCredentials: false,
58
+ maxAgeSeconds: 600,
59
+ },
60
+ },
61
+ card: {
62
+ name: 'DeepSeek Harness Agent',
63
+ description:
64
+ 'A DeepSeek Harness agent exposed over the Agent2Agent protocol. It can read and write files, run commands, search the web, and carry out multi-step tasks in its workspace.',
65
+ version: '1.0.0',
66
+ provider: null,
67
+ documentationUrl: null,
68
+ iconUrl: null,
69
+ defaultInputModes: ['text/plain', 'application/json', 'image/png', 'image/jpeg', 'image/webp'],
70
+ defaultOutputModes: ['text/plain', 'application/json'],
71
+ skills: DEFAULT_SKILLS,
72
+ extensions: [],
73
+ extended: {
74
+ description: null,
75
+ documentationUrl: null,
76
+ skills: [],
77
+ },
78
+ },
79
+ auth: {
80
+ mode: 'none',
81
+ tokens: [],
82
+ clients: [],
83
+ token: null,
84
+ tokenEnv: null,
85
+ headerName: 'x-api-key',
86
+ allowRemoteUnauthenticated: false,
87
+ realm: 'a2a',
88
+ },
89
+ agent: {
90
+ preset: null,
91
+ provider: null,
92
+ model: null,
93
+ reasoningEffort: null,
94
+ maxTokens: null,
95
+ cwd: null,
96
+ sandboxMode: 'workspace-write',
97
+ approvalPolicy: 'never',
98
+ maxConcurrentTasks: 4,
99
+ queueTimeoutMs: 60000,
100
+ turnTimeoutMs: 900000,
101
+ contextIdleTtlMs: 900000,
102
+ },
103
+ limits: {
104
+ maxRequestBytes: 8 * 1024 * 1024,
105
+ maxFileBytes: 16 * 1024 * 1024,
106
+ maxPromptChars: 200000,
107
+ maxArtifactChars: 200000,
108
+ },
109
+ files: {
110
+ enabled: true,
111
+ directory: '.a2a-inbox',
112
+ retentionMs: 24 * 60 * 60 * 1000,
113
+ },
114
+ tasks: {
115
+ ttlMs: 24 * 60 * 60 * 1000,
116
+ maxTasks: 2000,
117
+ sweepIntervalMs: 60000,
118
+ },
119
+ push: {
120
+ allowPrivateTargets: false,
121
+ timeoutMs: 5000,
122
+ },
123
+ signing: {
124
+ enabled: false,
125
+ privateKeyPem: null,
126
+ privateKeyFile: null,
127
+ keyId: null,
128
+ algorithm: 'ES256',
129
+ jku: null,
130
+ cacheSeconds: 300,
131
+ },
132
+ logging: {
133
+ requests: false,
134
+ },
135
+ }
136
+
137
+ const TOP_LEVEL_KEYS = new Set(Object.keys(DEFAULT_CONFIG))
138
+
139
+ const SECTION_KEYS = {
140
+ server: new Set(Object.keys(DEFAULT_CONFIG.server)),
141
+ 'server.cors': new Set(Object.keys(DEFAULT_CONFIG.server.cors)),
142
+ card: new Set(Object.keys(DEFAULT_CONFIG.card)),
143
+ 'card.provider': new Set(['organization', 'url']),
144
+ 'card.extended': new Set(Object.keys(DEFAULT_CONFIG.card.extended)),
145
+ auth: new Set(Object.keys(DEFAULT_CONFIG.auth)),
146
+ 'auth.clients': new Set(['id', 'token']),
147
+ agent: new Set(Object.keys(DEFAULT_CONFIG.agent)),
148
+ limits: new Set(Object.keys(DEFAULT_CONFIG.limits)),
149
+ files: new Set(Object.keys(DEFAULT_CONFIG.files)),
150
+ tasks: new Set(Object.keys(DEFAULT_CONFIG.tasks)),
151
+ push: new Set(Object.keys(DEFAULT_CONFIG.push)),
152
+ signing: new Set(Object.keys(DEFAULT_CONFIG.signing)),
153
+ logging: new Set(Object.keys(DEFAULT_CONFIG.logging)),
154
+ skills: new Set(['id', 'name', 'description', 'tags', 'examples', 'inputModes', 'outputModes', 'securityRequirements']),
155
+ extensions: new Set(['uri', 'description', 'required', 'params']),
156
+ }
157
+
158
+ /** Throw a configuration error with its exact path. */
159
+ function fail(path, message) {
160
+ throw new ConfigError(`${path}: ${message}`)
161
+ }
162
+
163
+ /** Reject unknown keys so a typo cannot silently fall back to a default. */
164
+ function checkKeys(value, path) {
165
+ if (!isPlainObject(value)) return
166
+ const allowed = SECTION_KEYS[path]
167
+ if (allowed === undefined) return
168
+ for (const key of Object.keys(value)) {
169
+ if (!allowed.has(key)) fail(path, `unknown field "${key}"`)
170
+ }
171
+ }
172
+
173
+ function expectObject(value, path) {
174
+ if (!isPlainObject(value)) fail(path, 'must be an object')
175
+ return value
176
+ }
177
+
178
+ function expectBoolean(value, path) {
179
+ if (typeof value !== 'boolean') fail(path, 'must be a boolean')
180
+ return value
181
+ }
182
+
183
+ function expectString(value, path, { allowEmpty = false } = {}) {
184
+ if (typeof value !== 'string') fail(path, 'must be a string')
185
+ if (!allowEmpty && value.trim() === '') fail(path, 'must not be empty')
186
+ return value
187
+ }
188
+
189
+ function expectNullableString(value, path, options) {
190
+ if (value === null || value === undefined) return null
191
+ return expectString(value, path, options)
192
+ }
193
+
194
+ function expectInteger(value, path, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
195
+ if (!Number.isInteger(value)) fail(path, 'must be an integer')
196
+ if (value < min || value > max) fail(path, `must be between ${min} and ${max}`)
197
+ return value
198
+ }
199
+
200
+ function expectEnum(value, path, allowed) {
201
+ if (!allowed.includes(value)) fail(path, `must be one of ${allowed.map((item) => JSON.stringify(item)).join(', ')}`)
202
+ return value
203
+ }
204
+
205
+ function expectStringArray(value, path, { nonEmpty = false } = {}) {
206
+ if (!Array.isArray(value)) fail(path, 'must be an array of strings')
207
+ const items = value.map((item, index) => expectString(item, `${path}[${index}]`, { allowEmpty: false }))
208
+ if (nonEmpty && items.length === 0) fail(path, 'must contain at least one entry')
209
+ return items
210
+ }
211
+
212
+ function validatePath(value, path, { reserved = false } = {}) {
213
+ expectString(value, path)
214
+ if (!value.startsWith('/')) fail(path, 'must start with "/"')
215
+ if (value === '/') fail(path, 'must not be the server root')
216
+ if (value.endsWith('/')) fail(path, 'must not end with "/"')
217
+ if (/[?#\s]/.test(value)) fail(path, 'must not contain whitespace, "?" or "#"')
218
+ if (reserved) {
219
+ for (const prefix of RESERVED_MOUNT_PREFIXES) {
220
+ if (value === prefix || value.startsWith(`${prefix}/`)) {
221
+ fail(path, `"${value}" collides with a Harness Web route (${RESERVED_MOUNT_PREFIXES.join(', ')})`)
222
+ }
223
+ }
224
+ }
225
+ return value
226
+ }
227
+
228
+ function validateSkills(value, path) {
229
+ if (!Array.isArray(value)) fail(path, 'must be an array')
230
+ return value.map((skill, index) => {
231
+ const at = `${path}[${index}]`
232
+ expectObject(skill, at)
233
+ checkKeys(skill, 'skills')
234
+ const id = expectString(skill.id, `${at}.id`)
235
+ const name = expectString(skill.name, `${at}.name`)
236
+ const description = expectString(skill.description, `${at}.description`)
237
+ const tags = expectStringArray(skill.tags ?? [], `${at}.tags`)
238
+ const result = { id, name, description, tags }
239
+ if (skill.examples !== undefined) result.examples = expectStringArray(skill.examples, `${at}.examples`)
240
+ if (skill.inputModes !== undefined) result.inputModes = expectStringArray(skill.inputModes, `${at}.inputModes`)
241
+ if (skill.outputModes !== undefined) result.outputModes = expectStringArray(skill.outputModes, `${at}.outputModes`)
242
+ if (skill.securityRequirements !== undefined) {
243
+ if (!Array.isArray(skill.securityRequirements)) fail(`${at}.securityRequirements`, 'must be an array')
244
+ result.securityRequirements = skill.securityRequirements
245
+ }
246
+ return result
247
+ })
248
+ }
249
+
250
+ function validateExtensions(value, path) {
251
+ if (!Array.isArray(value)) fail(path, 'must be an array')
252
+ return value.map((extension, index) => {
253
+ const at = `${path}[${index}]`
254
+ expectObject(extension, at)
255
+ checkKeys(extension, 'extensions')
256
+ return {
257
+ uri: expectString(extension.uri, `${at}.uri`),
258
+ description: expectNullableString(extension.description, `${at}.description`) ?? '',
259
+ required: extension.required === undefined ? false : expectBoolean(extension.required, `${at}.required`),
260
+ params: extension.params === undefined ? undefined : extension.params,
261
+ }
262
+ })
263
+ }
264
+
265
+ function validateCors(cors, path) {
266
+ expectObject(cors, path)
267
+ checkKeys(cors, 'server.cors')
268
+ const result = {
269
+ enabled: expectBoolean(cors.enabled, `${path}.enabled`),
270
+ origins: expectStringArray(cors.origins, `${path}.origins`),
271
+ allowCredentials: expectBoolean(cors.allowCredentials, `${path}.allowCredentials`),
272
+ maxAgeSeconds: expectInteger(cors.maxAgeSeconds, `${path}.maxAgeSeconds`, { min: 0, max: 86400 }),
273
+ }
274
+ if (result.allowCredentials && result.origins.includes('*')) {
275
+ fail(`${path}.origins`, 'a wildcard origin cannot be combined with allowCredentials')
276
+ }
277
+ return result
278
+ }
279
+
280
+ function validateClients(auth, path) {
281
+ if (!Array.isArray(auth.clients)) fail(`${path}.clients`, 'must be an array')
282
+ const seen = new Set()
283
+ return auth.clients.map((client, index) => {
284
+ const at = `${path}.clients[${index}]`
285
+ expectObject(client, at)
286
+ checkKeys(client, 'auth.clients')
287
+ const id = expectString(client.id, `${at}.id`)
288
+ if (seen.has(id)) fail(`${at}.id`, `duplicate client id "${id}"`)
289
+ seen.add(id)
290
+ return { id, token: expectString(client.token, `${at}.token`) }
291
+ })
292
+ }
293
+
294
+ function validateSigning(signing, path) {
295
+ expectObject(signing, path)
296
+ checkKeys(signing, 'signing')
297
+ const enabled = expectBoolean(signing.enabled, `${path}.enabled`)
298
+ const result = {
299
+ enabled,
300
+ privateKeyPem: expectNullableString(signing.privateKeyPem, `${path}.privateKeyPem`, { allowEmpty: false }),
301
+ privateKeyFile: expectNullableString(signing.privateKeyFile, `${path}.privateKeyFile`, { allowEmpty: false }),
302
+ keyId: expectNullableString(signing.keyId, `${path}.keyId`, { allowEmpty: false }),
303
+ algorithm: expectEnum(signing.algorithm, `${path}.algorithm`, [
304
+ 'ES256',
305
+ 'ES384',
306
+ 'ES512',
307
+ 'RS256',
308
+ 'RS384',
309
+ 'RS512',
310
+ 'PS256',
311
+ 'PS384',
312
+ 'PS512',
313
+ 'EdDSA',
314
+ ]),
315
+ jku: expectNullableString(signing.jku, `${path}.jku`, { allowEmpty: false }),
316
+ cacheSeconds: expectInteger(signing.cacheSeconds, `${path}.cacheSeconds`, { min: 0, max: 86400 }),
317
+ }
318
+ if (enabled) {
319
+ if (!result.privateKeyPem && !result.privateKeyFile) {
320
+ fail(`${path}.privateKeyPem`, 'signing requires privateKeyPem or privateKeyFile')
321
+ }
322
+ if (result.privateKeyPem && result.privateKeyFile) {
323
+ fail(`${path}.privateKeyFile`, 'set only one of privateKeyPem and privateKeyFile')
324
+ }
325
+ if (!result.keyId) fail(`${path}.keyId`, 'signing requires keyId')
326
+ }
327
+ return result
328
+ }
329
+
330
+ /**
331
+ * Merge user configuration over the defaults and validate it.
332
+ *
333
+ * @param {unknown} raw the loader-provided row configuration
334
+ * @returns {Readonly<object>} a validated, deep-frozen configuration
335
+ * @throws {ConfigError} on any invalid or unknown field
336
+ */
337
+ export function normalizeConfig(raw) {
338
+ if (raw !== undefined && raw !== null && !isPlainObject(raw)) {
339
+ throw new ConfigError('config: must be a mapping')
340
+ }
341
+ const merged = deepMerge(DEFAULT_CONFIG, raw ?? {})
342
+
343
+ for (const key of Object.keys(merged)) {
344
+ if (!TOP_LEVEL_KEYS.has(key)) throw new ConfigError(`config: unknown field "${key}"`)
345
+ }
346
+
347
+ // server
348
+ expectObject(merged.server, 'server')
349
+ checkKeys(merged.server, 'server')
350
+ merged.server.mountPath = validatePath(merged.server.mountPath, 'server.mountPath', { reserved: true })
351
+ merged.server.cardPath = validatePath(merged.server.cardPath, 'server.cardPath')
352
+ merged.server.publicUrl = expectNullableString(merged.server.publicUrl, 'server.publicUrl', { allowEmpty: false })
353
+ if (merged.server.publicUrl !== null) {
354
+ let url
355
+ try {
356
+ url = new URL(merged.server.publicUrl)
357
+ } catch {
358
+ fail('server.publicUrl', 'must be an absolute http(s) URL')
359
+ }
360
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
361
+ fail('server.publicUrl', 'must use http or https')
362
+ }
363
+ if (url.search || url.hash) fail('server.publicUrl', 'must not contain a query string or fragment')
364
+ merged.server.publicUrl = merged.server.publicUrl.replace(/\/+$/, '')
365
+ }
366
+ merged.server.trustProxy = expectBoolean(merged.server.trustProxy, 'server.trustProxy')
367
+ merged.server.streaming = expectBoolean(merged.server.streaming, 'server.streaming')
368
+ merged.server.pushNotifications = expectBoolean(merged.server.pushNotifications, 'server.pushNotifications')
369
+ merged.server.extendedCard = expectBoolean(merged.server.extendedCard, 'server.extendedCard')
370
+ merged.server.cardCacheSeconds = expectInteger(merged.server.cardCacheSeconds, 'server.cardCacheSeconds', {
371
+ min: 0,
372
+ max: 86400,
373
+ })
374
+ merged.server.health = expectBoolean(merged.server.health, 'server.health')
375
+ merged.server.logRequests = expectBoolean(merged.server.logRequests, 'server.logRequests')
376
+ merged.server.cors = validateCors(merged.server.cors, 'server.cors')
377
+
378
+ // card
379
+ expectObject(merged.card, 'card')
380
+ checkKeys(merged.card, 'card')
381
+ merged.card.name = expectString(merged.card.name, 'card.name')
382
+ merged.card.description = expectString(merged.card.description, 'card.description')
383
+ merged.card.version = expectString(merged.card.version, 'card.version')
384
+ merged.card.documentationUrl = expectNullableString(merged.card.documentationUrl, 'card.documentationUrl', {
385
+ allowEmpty: false,
386
+ })
387
+ merged.card.iconUrl = expectNullableString(merged.card.iconUrl, 'card.iconUrl', { allowEmpty: false })
388
+ if (merged.card.provider !== null) {
389
+ expectObject(merged.card.provider, 'card.provider')
390
+ checkKeys(merged.card.provider, 'card.provider')
391
+ merged.card.provider = {
392
+ organization: expectString(merged.card.provider.organization, 'card.provider.organization'),
393
+ url: expectString(merged.card.provider.url, 'card.provider.url'),
394
+ }
395
+ }
396
+ merged.card.defaultInputModes = expectStringArray(merged.card.defaultInputModes, 'card.defaultInputModes', {
397
+ nonEmpty: true,
398
+ })
399
+ merged.card.defaultOutputModes = expectStringArray(merged.card.defaultOutputModes, 'card.defaultOutputModes', {
400
+ nonEmpty: true,
401
+ })
402
+ merged.card.skills = validateSkills(merged.card.skills, 'card.skills')
403
+ if (merged.card.skills.length === 0) fail('card.skills', 'must contain at least one skill')
404
+ merged.card.extensions = validateExtensions(merged.card.extensions, 'card.extensions')
405
+ expectObject(merged.card.extended, 'card.extended')
406
+ checkKeys(merged.card.extended, 'card.extended')
407
+ merged.card.extended = {
408
+ description: expectNullableString(merged.card.extended.description, 'card.extended.description', {
409
+ allowEmpty: false,
410
+ }),
411
+ documentationUrl: expectNullableString(merged.card.extended.documentationUrl, 'card.extended.documentationUrl', {
412
+ allowEmpty: false,
413
+ }),
414
+ skills: validateSkills(merged.card.extended.skills ?? [], 'card.extended.skills'),
415
+ }
416
+
417
+ // auth
418
+ expectObject(merged.auth, 'auth')
419
+ checkKeys(merged.auth, 'auth')
420
+ merged.auth.mode = expectEnum(merged.auth.mode, 'auth.mode', ['none', 'bearer', 'apiKey'])
421
+ merged.auth.tokens = expectStringArray(merged.auth.tokens, 'auth.tokens')
422
+ merged.auth.clients = validateClients(merged.auth, 'auth')
423
+ merged.auth.token = expectNullableString(merged.auth.token, 'auth.token', { allowEmpty: false })
424
+ merged.auth.tokenEnv = expectNullableString(merged.auth.tokenEnv, 'auth.tokenEnv', { allowEmpty: false })
425
+ merged.auth.headerName = expectString(merged.auth.headerName, 'auth.headerName').toLowerCase()
426
+ if (!/^[a-z0-9!#$%&'*+.^_`|~-]+$/.test(merged.auth.headerName)) {
427
+ fail('auth.headerName', 'must be a valid HTTP header name')
428
+ }
429
+ merged.auth.allowRemoteUnauthenticated = expectBoolean(
430
+ merged.auth.allowRemoteUnauthenticated,
431
+ 'auth.allowRemoteUnauthenticated',
432
+ )
433
+ merged.auth.realm = expectString(merged.auth.realm, 'auth.realm')
434
+
435
+ // agent
436
+ expectObject(merged.agent, 'agent')
437
+ checkKeys(merged.agent, 'agent')
438
+ merged.agent.preset = expectNullableString(merged.agent.preset, 'agent.preset', { allowEmpty: false })
439
+ merged.agent.provider = expectNullableString(merged.agent.provider, 'agent.provider', { allowEmpty: false })
440
+ merged.agent.model = expectNullableString(merged.agent.model, 'agent.model', { allowEmpty: false })
441
+ if ((merged.agent.provider === null) !== (merged.agent.model === null)) {
442
+ fail('agent.model', 'provider and model must be configured together')
443
+ }
444
+ merged.agent.reasoningEffort = expectNullableString(merged.agent.reasoningEffort, 'agent.reasoningEffort', {
445
+ allowEmpty: false,
446
+ })
447
+ merged.agent.maxTokens =
448
+ merged.agent.maxTokens === null
449
+ ? null
450
+ : expectInteger(merged.agent.maxTokens, 'agent.maxTokens', { min: 1, max: 10000000 })
451
+ merged.agent.cwd = expectNullableString(merged.agent.cwd, 'agent.cwd', { allowEmpty: false })
452
+ if (merged.agent.cwd !== null && !merged.agent.cwd.startsWith('/')) {
453
+ fail('agent.cwd', 'must be an absolute path')
454
+ }
455
+ merged.agent.sandboxMode = expectEnum(merged.agent.sandboxMode, 'agent.sandboxMode', [
456
+ 'inherit',
457
+ 'read-only',
458
+ 'workspace-write',
459
+ 'danger-full-access',
460
+ ])
461
+ merged.agent.approvalPolicy = expectEnum(merged.agent.approvalPolicy, 'agent.approvalPolicy', ['inherit', 'ask', 'never'])
462
+ merged.agent.maxConcurrentTasks = expectInteger(merged.agent.maxConcurrentTasks, 'agent.maxConcurrentTasks', {
463
+ min: 1,
464
+ max: 64,
465
+ })
466
+ merged.agent.queueTimeoutMs = expectInteger(merged.agent.queueTimeoutMs, 'agent.queueTimeoutMs', {
467
+ min: 0,
468
+ max: 3600000,
469
+ })
470
+ merged.agent.turnTimeoutMs = expectInteger(merged.agent.turnTimeoutMs, 'agent.turnTimeoutMs', {
471
+ min: 1000,
472
+ max: 86400000,
473
+ })
474
+ merged.agent.contextIdleTtlMs = expectInteger(merged.agent.contextIdleTtlMs, 'agent.contextIdleTtlMs', {
475
+ min: 0,
476
+ max: 86400000,
477
+ })
478
+
479
+ // limits
480
+ expectObject(merged.limits, 'limits')
481
+ checkKeys(merged.limits, 'limits')
482
+ for (const [key, min, max] of [
483
+ ['maxRequestBytes', 1024, 256 * 1024 * 1024],
484
+ ['maxFileBytes', 0, 256 * 1024 * 1024],
485
+ ['maxPromptChars', 1024, 10000000],
486
+ ['maxArtifactChars', 1024, 10000000],
487
+ ]) {
488
+ merged.limits[key] = expectInteger(merged.limits[key], `limits.${key}`, { min, max })
489
+ }
490
+
491
+ // files
492
+ expectObject(merged.files, 'files')
493
+ checkKeys(merged.files, 'files')
494
+ merged.files.enabled = expectBoolean(merged.files.enabled, 'files.enabled')
495
+ merged.files.directory = expectString(merged.files.directory, 'files.directory')
496
+ if (merged.files.directory.startsWith('/') || merged.files.directory.includes('..')) {
497
+ fail('files.directory', 'must be a relative path inside the workspace without ".."')
498
+ }
499
+ merged.files.retentionMs = expectInteger(merged.files.retentionMs, 'files.retentionMs', {
500
+ min: 0,
501
+ max: 30 * 86400000,
502
+ })
503
+
504
+ // tasks
505
+ expectObject(merged.tasks, 'tasks')
506
+ checkKeys(merged.tasks, 'tasks')
507
+ merged.tasks.ttlMs = expectInteger(merged.tasks.ttlMs, 'tasks.ttlMs', { min: 0, max: 30 * 86400000 })
508
+ merged.tasks.maxTasks = expectInteger(merged.tasks.maxTasks, 'tasks.maxTasks', { min: 1, max: 100000 })
509
+ merged.tasks.sweepIntervalMs = expectInteger(merged.tasks.sweepIntervalMs, 'tasks.sweepIntervalMs', {
510
+ min: 5000,
511
+ max: 3600000,
512
+ })
513
+
514
+ // push
515
+ expectObject(merged.push, 'push')
516
+ checkKeys(merged.push, 'push')
517
+ merged.push.allowPrivateTargets = expectBoolean(merged.push.allowPrivateTargets, 'push.allowPrivateTargets')
518
+ merged.push.timeoutMs = expectInteger(merged.push.timeoutMs, 'push.timeoutMs', { min: 100, max: 60000 })
519
+
520
+ // signing
521
+ merged.signing = validateSigning(merged.signing, 'signing')
522
+
523
+ // logging
524
+ expectObject(merged.logging, 'logging')
525
+ checkKeys(merged.logging, 'logging')
526
+ merged.logging.requests = expectBoolean(merged.logging.requests, 'logging.requests')
527
+
528
+ merged.enabled = expectBoolean(merged.enabled, 'enabled')
529
+
530
+ return deepFreeze(merged)
531
+ }
532
+
533
+ /**
534
+ * Resolve the configured bearer/API-key credentials, including environment
535
+ * variables. Called at mount time so a restart picks up rotated secrets.
536
+ *
537
+ * @param {Readonly<object>} config normalized configuration
538
+ * @param {NodeJS.ProcessEnv} [env]
539
+ * @returns {{ id: string, token: string }[]}
540
+ */
541
+ export function resolveCredentials(config, env = process.env) {
542
+ const clients = new Map()
543
+ for (const client of config.auth.clients) clients.set(client.id, client.token)
544
+ config.auth.tokens.forEach((token, index) => {
545
+ const id = config.auth.tokens.length === 1 ? 'default' : `token-${index + 1}`
546
+ if (!clients.has(id)) clients.set(id, token)
547
+ })
548
+ if (config.auth.token) {
549
+ if (!clients.has('default')) clients.set('default', config.auth.token)
550
+ }
551
+ if (config.auth.tokenEnv) {
552
+ const raw = env[config.auth.tokenEnv]
553
+ if (typeof raw === 'string' && raw.trim() !== '') {
554
+ const values = raw
555
+ .split(/[\n,]/)
556
+ .map((value) => value.trim())
557
+ .filter(Boolean)
558
+ values.forEach((token, index) => {
559
+ const id = values.length === 1 ? 'env' : `env-${index + 1}`
560
+ if (!clients.has(id)) clients.set(id, token)
561
+ })
562
+ }
563
+ }
564
+ return [...clients].map(([id, token]) => ({ id, token }))
565
+ }