/**
* Centralized configuration with validation using Zod
*/
import { z } from 'zod';
import { logger } from './utils/logger.js';
import {
NOTION_API,
NOTION_KEY_PATTERNS,
API_VERSION_PATTERN,
SSE_CONFIG,
RATE_LIMIT,
SECURITY
} from './constants.js';
// Environment variable schema
const EnvSchema = z.object({
// Server configuration
PORT: z.string().transform(Number).pipe(z.number().int().min(1).max(65535)).optional(),
APIFY_CONTAINER_PORT: z.string().transform(Number).pipe(z.number().int().min(1).max(65535)).optional(),
APIFY_CONTAINER_URL: z.string().url().optional(),
ACTOR_WEB_SERVER_PORT: z.string().transform(Number).pipe(z.number().int().min(1).max(65535)).optional(),
ACTOR_WEB_SERVER_URL: z.string().url().optional(),
// Authentication
AUTH_TOKEN: z.string().min(8, 'AUTH_TOKEN must be at least 8 characters').optional(),
SECRET_TOKEN: z.string().min(8, 'SECRET_TOKEN must be at least 8 characters').optional(),
// Notion API
NOTION_TOKEN: z.string().optional(),
NOTION_API_VERSION: z.string().optional(),
OPENAPI_MCP_HEADERS: z.string().optional(),
BASE_URL: z.string().url().optional(),
// Logging
LOG_LEVEL: z.enum(['debug', 'info', 'warning', 'error']).optional(),
NODE_ENV: z.enum(['development', 'production', 'test']).optional(),
// Rate limiting
RATE_LIMIT_WINDOW_MS: z.string().transform(Number).pipe(z.number().int().positive()).optional(),
RATE_LIMIT_MAX_REQUESTS: z.string().transform(Number).pipe(z.number().int().positive()).optional(),
// SSE configuration
SSE_TIMEOUT_MS: z.string().transform(Number).pipe(z.number().int().positive()).optional(),
MAX_CONNECTIONS: z.string().transform(Number).pipe(z.number().int().positive()).optional(),
// CORS
ALLOWED_ORIGINS: z.string().optional(),
});
// Application configuration schema
const AppConfigSchema = z.object({
port: z.number().int().min(1).max(65535),
secretToken: z.string().min(8, 'Secret token must be at least 8 characters'),
notionApiKey: z.string().min(1, 'Notion API key is required'),
notionApiVersion: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Invalid API version format (expected YYYY-MM-DD)'),
// Optional configuration
logLevel: z.enum(['debug', 'info', 'warning', 'error']).default('info'),
nodeEnv: z.enum(['development', 'production', 'test']).default('production'),
// Rate limiting
rateLimit: z.object({
windowMs: z.number().int().positive().default(RATE_LIMIT.WINDOW_MS),
max: z.number().int().positive().default(RATE_LIMIT.MAX_REQUESTS),
}).default({}),
// SSE configuration
sse: z.object({
timeout: z.number().int().positive().default(SSE_CONFIG.TIMEOUT),
maxConnections: z.number().int().positive().default(SSE_CONFIG.MAX_CONNECTIONS),
}).default({}),
// CORS
cors: z.object({
origins: z.array(z.string()).default(['*']),
}).default({}),
// Container URL
containerUrl: z.string().url().optional(),
});
export type AppConfig = z.infer<typeof AppConfigSchema>;
/**
* Parse and validate environment variables
*/
function parseEnv() {
try {
return EnvSchema.parse(process.env);
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Invalid environment variables: ${error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}`);
}
throw error;
}
}
/**
* Build application configuration from environment and input
*/
export function buildConfig(input: {
port?: number;
secretToken?: string;
notionApiKey?: string;
notionApiVersion?: string;
}): AppConfig {
const env = parseEnv();
// Priority: input > environment > defaults
const port = env.ACTOR_WEB_SERVER_PORT || input.port || env.PORT || env.APIFY_CONTAINER_PORT || 8080;
const secretToken = input.secretToken || env.AUTH_TOKEN || env.SECRET_TOKEN;
const notionApiKey = input.notionApiKey || env.NOTION_TOKEN;
const notionApiVersion = input.notionApiVersion || env.NOTION_API_VERSION || NOTION_API.VERSION;
// Validate required fields
if (!secretToken) {
throw new Error('CRITICAL: secretToken is required. Provide it in Input or AUTH_TOKEN/SECRET_TOKEN env var.');
}
// Trim and validate secretToken length
const trimmedSecretToken = secretToken.trim();
if (trimmedSecretToken.length < SECURITY.MIN_SECRET_TOKEN_LENGTH) {
throw new Error(
`CRITICAL: secretToken must be at least ${SECURITY.MIN_SECRET_TOKEN_LENGTH} characters long. ` +
`Received: ${trimmedSecretToken.length} characters. ` +
`For security reasons, please use a longer token.`
);
}
if (!notionApiKey) {
throw new Error('CRITICAL: Notion API key is required. Provide it in Input or NOTION_TOKEN env var.');
}
// Validate Notion API key format
// Notion supports multiple key formats:
// - Legacy: secret_<43 alphanumeric characters>
// - New: ntn_<variable length alphanumeric characters>
const isValidFormat = NOTION_KEY_PATTERNS.LEGACY.test(notionApiKey) ||
NOTION_KEY_PATTERNS.NEW.test(notionApiKey);
if (!isValidFormat) {
// Basic validation: must start with known prefix and have reasonable length
const hasValidPrefix = notionApiKey.startsWith('secret_') || notionApiKey.startsWith('ntn_');
const hasValidLength = notionApiKey.length >= SECURITY.MIN_NOTION_KEY_LENGTH;
if (!hasValidPrefix || !hasValidLength) {
throw new Error(
'Invalid Notion API key format. ' +
'Key must start with "secret_" or "ntn_" and be at least 10 characters long. ' +
`Received key length: ${notionApiKey.length}`
);
}
// If format is close but not exact, just log warning (for backward compatibility)
logger.warning('Notion API key format may be non-standard', {
expectedFormats: ['secret_<43 alphanumeric>', 'ntn_<alphanumeric>'],
actualLength: notionApiKey.length,
});
}
// Validate API version format
if (!API_VERSION_PATTERN.test(notionApiVersion)) {
throw new Error(`Invalid Notion API version format: ${notionApiVersion}. Expected format: YYYY-MM-DD`);
}
// Build configuration object
const config = {
port,
secretToken: trimmedSecretToken, // Use trimmed version
notionApiKey,
notionApiVersion,
logLevel: (env.LOG_LEVEL || 'info') as 'debug' | 'info' | 'warning' | 'error',
nodeEnv: (env.NODE_ENV || 'production') as 'development' | 'production' | 'test',
rateLimit: {
windowMs: env.RATE_LIMIT_WINDOW_MS || RATE_LIMIT.WINDOW_MS,
max: env.RATE_LIMIT_MAX_REQUESTS || RATE_LIMIT.MAX_REQUESTS,
},
sse: {
timeout: env.SSE_TIMEOUT_MS || SSE_CONFIG.TIMEOUT,
maxConnections: env.MAX_CONNECTIONS || SSE_CONFIG.MAX_CONNECTIONS,
},
cors: {
origins: env.ALLOWED_ORIGINS ? env.ALLOWED_ORIGINS.split(',') : ['*'],
},
containerUrl: env.ACTOR_WEB_SERVER_URL || env.APIFY_CONTAINER_URL,
};
// Validate final configuration
return AppConfigSchema.parse(config);
}