All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m15s
370 lines
11 KiB
TypeScript
370 lines
11 KiB
TypeScript
// src/config/env.ts
|
|
/**
|
|
* @file Centralized, schema-validated configuration service.
|
|
* Implements ADR-007: Configuration and Secrets Management.
|
|
*
|
|
* This module parses and validates all environment variables at application startup.
|
|
* If any required configuration is missing or invalid, the application will fail fast
|
|
* with a clear error message.
|
|
*
|
|
* Usage:
|
|
* import { config } from './config/env';
|
|
* console.log(config.database.host);
|
|
*/
|
|
import { z } from 'zod';
|
|
|
|
// --- Schema Definitions ---
|
|
|
|
/**
|
|
* Helper to parse string to integer with default.
|
|
* Handles empty strings by treating them as undefined.
|
|
*/
|
|
const intWithDefault = (defaultValue: number) =>
|
|
z
|
|
.string()
|
|
.optional()
|
|
.transform((val) => (val && val.trim() !== '' ? parseInt(val, 10) : defaultValue))
|
|
.pipe(z.number().int());
|
|
|
|
/**
|
|
* Helper to parse string to float with default.
|
|
*/
|
|
const floatWithDefault = (defaultValue: number) =>
|
|
z
|
|
.string()
|
|
.optional()
|
|
.transform((val) => (val && val.trim() !== '' ? parseFloat(val) : defaultValue))
|
|
.pipe(z.number());
|
|
|
|
/**
|
|
* Helper to parse string 'true'/'false' to boolean.
|
|
*/
|
|
const booleanString = (defaultValue: boolean) =>
|
|
z
|
|
.string()
|
|
.optional()
|
|
.transform((val) => (val === undefined ? defaultValue : val === 'true'));
|
|
|
|
/**
|
|
* Database configuration schema.
|
|
*/
|
|
const databaseSchema = z.object({
|
|
host: z.string().min(1, 'DB_HOST is required'),
|
|
port: intWithDefault(5432),
|
|
user: z.string().min(1, 'DB_USER is required'),
|
|
password: z.string().min(1, 'DB_PASSWORD is required'),
|
|
name: z.string().min(1, 'DB_NAME is required'),
|
|
});
|
|
|
|
/**
|
|
* Redis configuration schema.
|
|
*/
|
|
const redisSchema = z.object({
|
|
url: z.string().url('REDIS_URL must be a valid URL'),
|
|
password: z.string().optional(),
|
|
});
|
|
|
|
/**
|
|
* Authentication configuration schema.
|
|
*/
|
|
const authSchema = z.object({
|
|
jwtSecret: z.string().min(32, 'JWT_SECRET must be at least 32 characters for security'),
|
|
jwtSecretPrevious: z.string().optional(), // For secret rotation (ADR-029)
|
|
});
|
|
|
|
/**
|
|
* SMTP/Email configuration schema.
|
|
* All fields are optional - email service degrades gracefully if not configured.
|
|
*/
|
|
const smtpSchema = z.object({
|
|
host: z.string().optional(),
|
|
port: intWithDefault(587),
|
|
user: z.string().optional(),
|
|
pass: z.string().optional(),
|
|
secure: booleanString(false),
|
|
fromEmail: z.string().email().optional(),
|
|
});
|
|
|
|
/**
|
|
* AI/Gemini configuration schema.
|
|
*/
|
|
const aiSchema = z.object({
|
|
geminiApiKey: z.string().optional(),
|
|
geminiRpm: intWithDefault(5),
|
|
priceQualityThreshold: floatWithDefault(0.5),
|
|
});
|
|
|
|
/**
|
|
* UPC API configuration schema.
|
|
* External APIs for product lookup by barcode.
|
|
*/
|
|
const upcSchema = z.object({
|
|
upcItemDbApiKey: z.string().optional(), // UPC Item DB API key (upcitemdb.com)
|
|
barcodeLookupApiKey: z.string().optional(), // Barcode Lookup API key (barcodelookup.com)
|
|
});
|
|
|
|
/**
|
|
* Google services configuration schema.
|
|
*/
|
|
const googleSchema = z.object({
|
|
mapsApiKey: z.string().optional(),
|
|
clientId: z.string().optional(),
|
|
clientSecret: z.string().optional(),
|
|
});
|
|
|
|
/**
|
|
* Worker concurrency configuration schema.
|
|
*/
|
|
const workerSchema = z.object({
|
|
concurrency: intWithDefault(1),
|
|
lockDuration: intWithDefault(30000),
|
|
emailConcurrency: intWithDefault(10),
|
|
analyticsConcurrency: intWithDefault(1),
|
|
cleanupConcurrency: intWithDefault(10),
|
|
weeklyAnalyticsConcurrency: intWithDefault(1),
|
|
});
|
|
|
|
/**
|
|
* Server configuration schema.
|
|
*/
|
|
const serverSchema = z.object({
|
|
nodeEnv: z.enum(['development', 'production', 'test', 'staging']).default('development'),
|
|
port: intWithDefault(3001),
|
|
frontendUrl: z.string().url().optional(),
|
|
baseUrl: z.string().optional(),
|
|
storagePath: z.string().default('/var/www/flyer-crawler.projectium.com/flyer-images'),
|
|
});
|
|
|
|
/**
|
|
* Error tracking configuration schema (ADR-015).
|
|
* Uses Bugsink (Sentry-compatible self-hosted error tracking).
|
|
*/
|
|
const sentrySchema = z.object({
|
|
dsn: z.string().optional(), // Sentry DSN for backend
|
|
enabled: booleanString(true),
|
|
environment: z.string().optional(),
|
|
debug: booleanString(false),
|
|
});
|
|
|
|
/**
|
|
* Complete environment configuration schema.
|
|
*/
|
|
const envSchema = z.object({
|
|
database: databaseSchema,
|
|
redis: redisSchema,
|
|
auth: authSchema,
|
|
smtp: smtpSchema,
|
|
ai: aiSchema,
|
|
upc: upcSchema,
|
|
google: googleSchema,
|
|
worker: workerSchema,
|
|
server: serverSchema,
|
|
sentry: sentrySchema,
|
|
});
|
|
|
|
export type EnvConfig = z.infer<typeof envSchema>;
|
|
|
|
// --- Configuration Loading ---
|
|
|
|
/**
|
|
* Maps environment variables to the configuration structure.
|
|
* This is the single source of truth for which env vars map to which config keys.
|
|
*/
|
|
function loadEnvVars(): unknown {
|
|
return {
|
|
database: {
|
|
host: process.env.DB_HOST,
|
|
port: process.env.DB_PORT,
|
|
user: process.env.DB_USER,
|
|
password: process.env.DB_PASSWORD,
|
|
name: process.env.DB_NAME,
|
|
},
|
|
redis: {
|
|
url: process.env.REDIS_URL,
|
|
password: process.env.REDIS_PASSWORD,
|
|
},
|
|
auth: {
|
|
jwtSecret: process.env.JWT_SECRET,
|
|
jwtSecretPrevious: process.env.JWT_SECRET_PREVIOUS,
|
|
},
|
|
smtp: {
|
|
host: process.env.SMTP_HOST,
|
|
port: process.env.SMTP_PORT,
|
|
user: process.env.SMTP_USER,
|
|
pass: process.env.SMTP_PASS,
|
|
secure: process.env.SMTP_SECURE,
|
|
fromEmail: process.env.SMTP_FROM_EMAIL,
|
|
},
|
|
ai: {
|
|
geminiApiKey: process.env.GEMINI_API_KEY,
|
|
geminiRpm: process.env.GEMINI_RPM,
|
|
priceQualityThreshold: process.env.AI_PRICE_QUALITY_THRESHOLD,
|
|
},
|
|
upc: {
|
|
upcItemDbApiKey: process.env.UPC_ITEM_DB_API_KEY,
|
|
barcodeLookupApiKey: process.env.BARCODE_LOOKUP_API_KEY,
|
|
},
|
|
google: {
|
|
mapsApiKey: process.env.GOOGLE_MAPS_API_KEY,
|
|
clientId: process.env.GOOGLE_CLIENT_ID,
|
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
|
},
|
|
worker: {
|
|
concurrency: process.env.WORKER_CONCURRENCY,
|
|
lockDuration: process.env.WORKER_LOCK_DURATION,
|
|
emailConcurrency: process.env.EMAIL_WORKER_CONCURRENCY,
|
|
analyticsConcurrency: process.env.ANALYTICS_WORKER_CONCURRENCY,
|
|
cleanupConcurrency: process.env.CLEANUP_WORKER_CONCURRENCY,
|
|
weeklyAnalyticsConcurrency: process.env.WEEKLY_ANALYTICS_WORKER_CONCURRENCY,
|
|
},
|
|
server: {
|
|
nodeEnv: process.env.NODE_ENV,
|
|
port: process.env.PORT,
|
|
frontendUrl: process.env.FRONTEND_URL,
|
|
baseUrl: process.env.BASE_URL,
|
|
storagePath: process.env.STORAGE_PATH,
|
|
},
|
|
sentry: {
|
|
dsn: process.env.SENTRY_DSN,
|
|
enabled: process.env.SENTRY_ENABLED,
|
|
environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV,
|
|
debug: process.env.SENTRY_DEBUG,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validates and parses environment configuration.
|
|
* Throws a descriptive error if validation fails.
|
|
*/
|
|
function parseConfig(): EnvConfig {
|
|
const rawConfig = loadEnvVars();
|
|
const result = envSchema.safeParse(rawConfig);
|
|
|
|
if (!result.success) {
|
|
const errors = result.error.issues.map((issue) => {
|
|
const path = issue.path.join('.');
|
|
return ` - ${path}: ${issue.message}`;
|
|
});
|
|
|
|
const errorMessage = [
|
|
'',
|
|
'╔════════════════════════════════════════════════════════════════╗',
|
|
'║ CONFIGURATION ERROR - APPLICATION STARTUP ║',
|
|
'╚════════════════════════════════════════════════════════════════╝',
|
|
'',
|
|
'The following environment variables are missing or invalid:',
|
|
'',
|
|
...errors,
|
|
'',
|
|
'Please check your .env file or environment configuration.',
|
|
'See ADR-007 for the complete list of required environment variables.',
|
|
'',
|
|
].join('\n');
|
|
|
|
// In test/staging environment, throw instead of exiting to allow test frameworks to catch
|
|
// and to provide better visibility into config errors during staging deployments
|
|
if (process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'staging') {
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
console.error(errorMessage);
|
|
process.exit(1);
|
|
}
|
|
|
|
return result.data;
|
|
}
|
|
|
|
// --- Exported Configuration ---
|
|
|
|
/**
|
|
* The validated application configuration.
|
|
* This is a singleton that is parsed once at module load time.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* import { config } from './config/env';
|
|
*
|
|
* // Access database config
|
|
* const pool = new Pool({
|
|
* host: config.database.host,
|
|
* port: config.database.port,
|
|
* user: config.database.user,
|
|
* password: config.database.password,
|
|
* database: config.database.name,
|
|
* });
|
|
*
|
|
* // Check environment
|
|
* if (config.server.isProduction) {
|
|
* // production-only logic
|
|
* }
|
|
* ```
|
|
*/
|
|
export const config: EnvConfig = parseConfig();
|
|
|
|
// --- Convenience Helpers ---
|
|
|
|
/**
|
|
* Returns true if running in production environment.
|
|
*/
|
|
export const isProduction = config.server.nodeEnv === 'production';
|
|
|
|
/**
|
|
* Returns true if running in test environment.
|
|
*/
|
|
export const isTest = config.server.nodeEnv === 'test';
|
|
|
|
/**
|
|
* Returns true if running in development environment.
|
|
*/
|
|
export const isDevelopment = config.server.nodeEnv === 'development';
|
|
|
|
/**
|
|
* Returns true if running in staging environment.
|
|
*/
|
|
export const isStaging = config.server.nodeEnv === 'staging';
|
|
|
|
/**
|
|
* Returns true if running in a test-like environment (test or staging).
|
|
* Use this for behaviors that should be shared between unit/integration tests
|
|
* and the staging deployment server, such as:
|
|
* - Using mock AI services (no GEMINI_API_KEY required)
|
|
* - Verbose error logging
|
|
* - Fallback URL handling
|
|
*
|
|
* Do NOT use this for security bypasses (auth, rate limiting) - those should
|
|
* only be active in NODE_ENV=test, not staging.
|
|
*/
|
|
export const isTestLikeEnvironment = isTest || isStaging;
|
|
|
|
/**
|
|
* Returns true if SMTP is configured (all required fields present).
|
|
*/
|
|
export const isSmtpConfigured =
|
|
!!config.smtp.host && !!config.smtp.user && !!config.smtp.pass && !!config.smtp.fromEmail;
|
|
|
|
/**
|
|
* Returns true if AI services are configured.
|
|
*/
|
|
export const isAiConfigured = !!config.ai.geminiApiKey;
|
|
|
|
/**
|
|
* Returns true if Google Maps is configured.
|
|
*/
|
|
export const isGoogleMapsConfigured = !!config.google.mapsApiKey;
|
|
|
|
/**
|
|
* Returns true if Sentry/Bugsink error tracking is configured and enabled.
|
|
*/
|
|
export const isSentryConfigured = !!config.sentry.dsn && config.sentry.enabled;
|
|
|
|
/**
|
|
* Returns true if UPC Item DB API is configured.
|
|
*/
|
|
export const isUpcItemDbConfigured = !!config.upc.upcItemDbApiKey;
|
|
|
|
/**
|
|
* Returns true if Barcode Lookup API is configured.
|
|
*/
|
|
export const isBarcodeLookupConfigured = !!config.upc.barcodeLookupApiKey;
|