diff --git a/package-lock.json b/package-lock.json index 48db4d53..d9eab24e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2391,9 +2391,9 @@ } }, "node_modules/@llmist/cli": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/@llmist/cli/-/cli-16.0.3.tgz", - "integrity": "sha512-t45mK7foJpNyvmff2P7CwyN5Cw3Hd5shJjZB83THwGEsX69Nb1Q9NCLxnWSdN/AXT2COdsLUvDopoGedMcmCuw==", + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/@llmist/cli/-/cli-16.1.0.tgz", + "integrity": "sha512-UO3294pwMeijqyo+3pg8m2dL9XZB4irDmKjHaSkY6Lk9YRH54E4HpqnaS1V23HWaKNQidNi9SAQXMGUqBOuemQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -2406,7 +2406,7 @@ "jiti": "^2.6.1", "js-toml": "^1.0.2", "js-yaml": "^4.1.0", - "llmist": "^16.0.3", + "llmist": "^16.1.0", "marked": "^15.0.12", "marked-terminal": "^7.3.0", "zod": "^4.1.12" @@ -4724,7 +4724,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.2", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -7754,9 +7756,9 @@ "license": "MIT" }, "node_modules/llmist": { - "version": "16.0.4", - "resolved": "https://registry.npmjs.org/llmist/-/llmist-16.0.4.tgz", - "integrity": "sha512-W20kOXSZaW6n8ruKG8sWU2zH9fXpqsRN8QIs68NWBZT11HtwriOr7gMIaYu8rGRYjSGPIIufX8Xmo9oSg1DYUg==", + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/llmist/-/llmist-16.1.0.tgz", + "integrity": "sha512-saSSxHR8onoD4KbVAz5wuB64SIdC33LRZk1EpukjsS0JSJb6r2Be/0ry55sPY2fiARHN6S7kWdxIJLRNSFISog==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.69.0", @@ -7803,15 +7805,15 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, "node_modules/lodash.camelcase": { diff --git a/package.json b/package.json index a22fdb1c..a38faf99 100644 --- a/package.json +++ b/package.json @@ -130,5 +130,8 @@ }, "engines": { "node": ">=22.0.0" + }, + "overrides": { + "lodash-es": "^4.18.1" } } diff --git a/src/dashboard.ts b/src/dashboard.ts index e3d781c7..be01ec14 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -28,6 +28,7 @@ import { resolveUserFromSession } from './api/auth/session.js'; import { computeEffectiveOrgId } from './api/context.js'; import { appRouter } from './api/router.js'; import { registerBuiltInEngines } from './backends/bootstrap.js'; +import { validateCredentialMasterKey } from './db/crypto.js'; import { captureException, flush, setTag } from './sentry.js'; import { buildCorsMiddleware } from './utils/corsConfig.js'; @@ -106,6 +107,12 @@ app.onError((err, c) => { const port = Number(process.env.PORT) || 3001; async function startDashboard(): Promise { + const keyValidation = validateCredentialMasterKey(); + if (!keyValidation.valid) { + console.error(`[Dashboard] ${keyValidation.reason}`); + process.exit(1); + } + await initPrompts(); console.log(`[Dashboard] Starting on port ${port}`); serve({ fetch: app.fetch, port }); diff --git a/src/db/crypto.ts b/src/db/crypto.ts index 741f67a4..e1375ea7 100644 --- a/src/db/crypto.ts +++ b/src/db/crypto.ts @@ -22,6 +22,29 @@ export function isEncryptionEnabled(): boolean { return !!process.env.CREDENTIAL_MASTER_KEY; } +/** + * Validates the format of CREDENTIAL_MASTER_KEY without throwing. + * Returns `{ valid: true }` if the key is unset (encryption is opt-in) or correctly formatted. + * Returns `{ valid: false, reason: string }` if the key is set but malformed. + */ +export function validateCredentialMasterKey(): { valid: true } | { valid: false; reason: string } { + const hex = process.env.CREDENTIAL_MASTER_KEY; + if (!hex) return { valid: true }; + if (hex.length !== KEY_LENGTH * 2) { + return { + valid: false, + reason: `CREDENTIAL_MASTER_KEY must be a ${KEY_LENGTH * 2}-char hex string (${KEY_LENGTH} bytes). Got ${hex.length} chars.`, + }; + } + if (!/^[0-9a-fA-F]+$/.test(hex)) { + return { + valid: false, + reason: `CREDENTIAL_MASTER_KEY contains non-hex characters. Must be a ${KEY_LENGTH * 2}-char hex string.`, + }; + } + return { valid: true }; +} + /** Returns true if the value has the encrypted-value prefix. */ export function isEncryptedValue(value: string): boolean { return value.startsWith(PREFIX); diff --git a/src/router/index.ts b/src/router/index.ts index 61182e02..7ad590d3 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -6,6 +6,7 @@ import '../integrations/bootstrap.js'; import { initPrompts } from '../agents/prompts/index.js'; import { registerBuiltInEngines } from '../backends/bootstrap.js'; import { initAgentMessages } from '../config/agentMessages.js'; +import { validateCredentialMasterKey } from '../db/crypto.js'; import { seedAgentDefinitions } from '../db/seeds/seedAgentDefinitions.js'; import { registerBuiltInTriggers } from '../triggers/builtins.js'; import { createTriggerRegistry } from '../triggers/registry.js'; @@ -193,6 +194,12 @@ process.on('unhandledRejection', (reason) => { async function startRouter(): Promise { const port = Number(process.env.PORT) || 3000; + const keyValidation = validateCredentialMasterKey(); + if (!keyValidation.valid) { + logger.error('Invalid CREDENTIAL_MASTER_KEY', { reason: keyValidation.reason }); + process.exit(1); + } + // Seed built-in agent definitions to DB, then initialize in-memory caches logger.info('Seeding agent definitions...'); await seedAgentDefinitions(); diff --git a/tests/unit/db/crypto.test.ts b/tests/unit/db/crypto.test.ts index 4b243985..68edac00 100644 --- a/tests/unit/db/crypto.test.ts +++ b/tests/unit/db/crypto.test.ts @@ -6,6 +6,7 @@ import { isEncryptedValue, isEncryptionEnabled, reEncryptCredential, + validateCredentialMasterKey, } from '../../../src/db/crypto.js'; // Generate a valid 32-byte hex key for tests @@ -186,4 +187,52 @@ describe('crypto', () => { ); }); }); + + describe('validateCredentialMasterKey', () => { + it('returns valid when CREDENTIAL_MASTER_KEY is not set', () => { + vi.stubEnv('CREDENTIAL_MASTER_KEY', ''); + const result = validateCredentialMasterKey(); + expect(result).toEqual({ valid: true }); + }); + + it('returns valid for a correct 64-char hex key', () => { + vi.stubEnv('CREDENTIAL_MASTER_KEY', TEST_KEY); + const result = validateCredentialMasterKey(); + expect(result).toEqual({ valid: true }); + }); + + it('returns invalid for a key that is too short', () => { + vi.stubEnv('CREDENTIAL_MASTER_KEY', 'abcd'); + const result = validateCredentialMasterKey(); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('64-char hex string'); + } + }); + + it('returns invalid for a key that is too long', () => { + vi.stubEnv('CREDENTIAL_MASTER_KEY', 'a'.repeat(128)); + const result = validateCredentialMasterKey(); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('64-char hex string'); + } + }); + + it('returns invalid for non-hex characters', () => { + // 63 valid hex chars + 1 invalid 'g' + vi.stubEnv('CREDENTIAL_MASTER_KEY', `${'a'.repeat(63)}g`); + const result = validateCredentialMasterKey(); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('non-hex'); + } + }); + + it('returns valid for uppercase hex', () => { + vi.stubEnv('CREDENTIAL_MASTER_KEY', TEST_KEY.toUpperCase()); + const result = validateCredentialMasterKey(); + expect(result).toEqual({ valid: true }); + }); + }); }); diff --git a/tests/unit/router/startup-validation.test.ts b/tests/unit/router/startup-validation.test.ts new file mode 100644 index 00000000..521c175c --- /dev/null +++ b/tests/unit/router/startup-validation.test.ts @@ -0,0 +1,86 @@ +import { randomBytes } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { validateCredentialMasterKey } from '../../../src/db/crypto.js'; + +/** + * Tests for startup validation behavior in router and dashboard. + * + * Both startRouter() and startDashboard() call validateCredentialMasterKey() + * and process.exit(1) if the key is malformed. Since both modules auto-execute + * at import time, we test the startup validation logic by verifying the + * validateCredentialMasterKey() + process.exit integration directly. + * + * This simulates the pattern used in both entry points: + * const keyValidation = validateCredentialMasterKey(); + * if (!keyValidation.valid) { + * ; + * process.exit(1); + * } + */ + +// Generate a valid 32-byte hex key for tests +const TEST_KEY = randomBytes(32).toString('hex'); + +describe('startup validation logic (router + dashboard pattern)', () => { + let processExitSpy: ReturnType; + + beforeEach(() => { + processExitSpy = vi.spyOn(process, 'exit').mockImplementation((_code?: number) => { + throw new Error(`process.exit(${_code})`); + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + processExitSpy.mockRestore(); + }); + + /** + * Simulates the startup validation block used in startRouter() and startDashboard(). + */ + function runStartupValidation(): void { + const keyValidation = validateCredentialMasterKey(); + if (!keyValidation.valid) { + process.exit(1); + } + } + + describe('when CREDENTIAL_MASTER_KEY is invalid', () => { + it('calls process.exit(1) for a too-short key', () => { + vi.stubEnv('CREDENTIAL_MASTER_KEY', 'tooshort'); + + expect(() => runStartupValidation()).toThrow('process.exit(1)'); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('calls process.exit(1) for a key with non-hex characters', () => { + vi.stubEnv('CREDENTIAL_MASTER_KEY', 'g'.repeat(64)); + + expect(() => runStartupValidation()).toThrow('process.exit(1)'); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('calls process.exit(1) for a key that is too long', () => { + vi.stubEnv('CREDENTIAL_MASTER_KEY', 'a'.repeat(128)); + + expect(() => runStartupValidation()).toThrow('process.exit(1)'); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('when CREDENTIAL_MASTER_KEY is valid or unset', () => { + it('does NOT call process.exit when key is unset', () => { + vi.stubEnv('CREDENTIAL_MASTER_KEY', ''); + + expect(() => runStartupValidation()).not.toThrow(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('does NOT call process.exit when key is a valid 64-char hex string', () => { + vi.stubEnv('CREDENTIAL_MASTER_KEY', TEST_KEY); + + expect(() => runStartupValidation()).not.toThrow(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + }); +});