Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 16 additions & 14 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,5 +130,8 @@
},
"engines": {
"node": ">=22.0.0"
},
"overrides": {
"lodash-es": "^4.18.1"
}
}
7 changes: 7 additions & 0 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -106,6 +107,12 @@ app.onError((err, c) => {
const port = Number(process.env.PORT) || 3001;

async function startDashboard(): Promise<void> {
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 });
Expand Down
23 changes: 23 additions & 0 deletions src/db/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -193,6 +194,12 @@ process.on('unhandledRejection', (reason) => {
async function startRouter(): Promise<void> {
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();
Expand Down
49 changes: 49 additions & 0 deletions tests/unit/db/crypto.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
isEncryptedValue,
isEncryptionEnabled,
reEncryptCredential,
validateCredentialMasterKey,
} from '../../../src/db/crypto.js';

// Generate a valid 32-byte hex key for tests
Expand Down Expand Up @@ -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 });
});
});
});
86 changes: 86 additions & 0 deletions tests/unit/router/startup-validation.test.ts
Original file line number Diff line number Diff line change
@@ -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) {
* <log error>;
* 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<typeof vi.spyOn>;

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();
});
});
});
Loading