fix(config): harden production backend config
This commit is contained in:
parent
e9afb9fa88
commit
e7d381f071
@ -2,35 +2,116 @@ import { z } from 'zod';
|
||||
import { baseBackendConfigSchema } from '@bytelyst/backend-config';
|
||||
import { PRODUCT_ID } from './product-config.js';
|
||||
|
||||
const envSchema = baseBackendConfigSchema.extend({
|
||||
export const DEFAULT_DEV_JWT_SECRET = 'dev-secret-do-not-use-in-prod';
|
||||
|
||||
function envBoolean(defaultValue: boolean) {
|
||||
return z.preprocess((value) => {
|
||||
if (value === undefined || value === '') return undefined;
|
||||
if (typeof value !== 'string') return value;
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
|
||||
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
|
||||
return value;
|
||||
}, z.boolean().default(defaultValue));
|
||||
}
|
||||
|
||||
function isBlank(value: string | undefined): boolean {
|
||||
return value === undefined || value.trim().length === 0;
|
||||
}
|
||||
|
||||
function addConfigIssue(ctx: z.RefinementCtx, path: string, message: string): void {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [path],
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
export const envSchema = baseBackendConfigSchema.extend({
|
||||
PORT: baseBackendConfigSchema.shape.PORT.default(4016),
|
||||
SERVICE_NAME: baseBackendConfigSchema.shape.SERVICE_NAME.default('notelett-backend'),
|
||||
DB_PROVIDER: baseBackendConfigSchema.shape.DB_PROVIDER.default('memory'),
|
||||
JWT_SECRET: z.string().default('dev-secret-do-not-use-in-prod'),
|
||||
JWT_SECRET: z.preprocess(
|
||||
value => (typeof value === 'string' && value.trim() === '' ? undefined : value),
|
||||
z.string().default(DEFAULT_DEV_JWT_SECRET)
|
||||
),
|
||||
COSMOS_DATABASE: baseBackendConfigSchema.shape.COSMOS_DATABASE.default('bytelyst'),
|
||||
PRODUCT_ID: z.string().default(PRODUCT_ID),
|
||||
PLATFORM_SERVICE_URL: z.string().default('http://localhost:4003'),
|
||||
EXTRACTION_SERVICE_URL: z.string().default('http://localhost:4005'),
|
||||
MCP_SERVER_URL: z.string().default('http://localhost:4007'),
|
||||
TELEMETRY_ENABLED: z.coerce.boolean().default(false),
|
||||
FEATURE_FLAGS_ENABLED: z.coerce.boolean().default(false),
|
||||
TELEMETRY_ENABLED: envBoolean(false),
|
||||
FEATURE_FLAGS_ENABLED: envBoolean(false),
|
||||
// ── LLM (@bytelyst/llm) ──
|
||||
LLM_PROVIDER: z.enum(['azure', 'openai', 'mock']).default('mock'),
|
||||
LLM_DEFAULT_MODEL: z.string().default('gpt-4o-mini'),
|
||||
LLM_VISION_MODEL: z.string().default('gpt-4o'),
|
||||
LLM_EMBEDDING_MODEL: z.string().default('text-embedding-3-small'),
|
||||
// ── Field Encryption (@bytelyst/field-encrypt) ──
|
||||
FIELD_ENCRYPT_ENABLED: z.coerce.boolean().default(true),
|
||||
FIELD_ENCRYPT_ENABLED: envBoolean(true),
|
||||
FIELD_ENCRYPT_KEY_PROVIDER: z.enum(['akv', 'env', 'memory']).default('memory'),
|
||||
FIELD_ENCRYPT_KEY: z.string().default(''),
|
||||
FIELD_ENCRYPT_MEK_NAME: z.string().default('notelett-mek'),
|
||||
AZURE_KEYVAULT_URL: z.string().default(''),
|
||||
// ── Palace (MemPalace) ──
|
||||
PALACE_ENABLED: z.coerce.boolean().default(true),
|
||||
PALACE_EXTRACTION_ENABLED: z.coerce.boolean().default(true),
|
||||
PALACE_ENABLED: envBoolean(true),
|
||||
PALACE_EXTRACTION_ENABLED: envBoolean(true),
|
||||
PALACE_WAKE_UP_BUDGET: z.coerce.number().int().min(100).default(600),
|
||||
PALACE_RELEVANCE_HALF_LIFE_DAYS: z.coerce.number().int().min(1).default(90),
|
||||
PALACE_DEDUP_THRESHOLD: z.coerce.number().min(0).max(1).default(0.90),
|
||||
}).superRefine((env, ctx) => {
|
||||
const isProduction = env.NODE_ENV === 'production';
|
||||
|
||||
if (env.FIELD_ENCRYPT_ENABLED && env.FIELD_ENCRYPT_KEY_PROVIDER === 'env' && isBlank(env.FIELD_ENCRYPT_KEY)) {
|
||||
addConfigIssue(ctx, 'FIELD_ENCRYPT_KEY', 'FIELD_ENCRYPT_KEY is required when FIELD_ENCRYPT_KEY_PROVIDER=env');
|
||||
}
|
||||
|
||||
if (env.FIELD_ENCRYPT_ENABLED && env.FIELD_ENCRYPT_KEY_PROVIDER === 'akv' && isBlank(env.AZURE_KEYVAULT_URL)) {
|
||||
addConfigIssue(ctx, 'AZURE_KEYVAULT_URL', 'AZURE_KEYVAULT_URL is required when FIELD_ENCRYPT_KEY_PROVIDER=akv');
|
||||
}
|
||||
|
||||
if (!isProduction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (env.JWT_SECRET === DEFAULT_DEV_JWT_SECRET) {
|
||||
addConfigIssue(ctx, 'JWT_SECRET', 'Production JWT_SECRET must not use the development default');
|
||||
}
|
||||
|
||||
if (env.JWT_SECRET.length < 32) {
|
||||
addConfigIssue(ctx, 'JWT_SECRET', 'Production JWT_SECRET must be at least 32 characters');
|
||||
}
|
||||
|
||||
if (env.DB_PROVIDER === 'memory') {
|
||||
addConfigIssue(ctx, 'DB_PROVIDER', 'Production DB_PROVIDER must be cosmos');
|
||||
}
|
||||
|
||||
if (env.DB_PROVIDER === 'cosmos') {
|
||||
if (isBlank(env.COSMOS_ENDPOINT)) {
|
||||
addConfigIssue(ctx, 'COSMOS_ENDPOINT', 'COSMOS_ENDPOINT is required in production');
|
||||
}
|
||||
if (isBlank(env.COSMOS_KEY)) {
|
||||
addConfigIssue(ctx, 'COSMOS_KEY', 'COSMOS_KEY is required in production');
|
||||
}
|
||||
if (isBlank(env.COSMOS_DATABASE)) {
|
||||
addConfigIssue(ctx, 'COSMOS_DATABASE', 'COSMOS_DATABASE is required in production');
|
||||
}
|
||||
}
|
||||
|
||||
if (!env.FIELD_ENCRYPT_ENABLED) {
|
||||
addConfigIssue(ctx, 'FIELD_ENCRYPT_ENABLED', 'Field encryption must be enabled in production');
|
||||
}
|
||||
|
||||
if (env.FIELD_ENCRYPT_KEY_PROVIDER === 'memory') {
|
||||
addConfigIssue(ctx, 'FIELD_ENCRYPT_KEY_PROVIDER', 'Production FIELD_ENCRYPT_KEY_PROVIDER must be akv or env');
|
||||
}
|
||||
});
|
||||
|
||||
export const config = envSchema.parse(process.env);
|
||||
export type BackendConfig = z.infer<typeof envSchema>;
|
||||
|
||||
export function parseConfig(env: Record<string, string | undefined> = process.env): BackendConfig {
|
||||
return envSchema.parse(env);
|
||||
}
|
||||
|
||||
export const config = parseConfig(process.env as Record<string, string | undefined>);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user