feat: harden production storage readiness

This commit is contained in:
Codex
2026-06-30 01:05:34 +08:00
parent f0cbc0b35a
commit b8242429bb
9 changed files with 435 additions and 2 deletions

View File

@@ -3,6 +3,8 @@ import { DEFAULT_DATABASE_URL, envBoolean, envList, envNumber, envString, loadDo
loadDotenv();
export interface WorkerConfig {
nodeEnv: string;
isProduction: boolean;
databaseUrl: string;
crmBatchSize: number;
crmPollIntervalMs: number;
@@ -59,7 +61,96 @@ export interface WorkerConfig {
supabaseStorageServiceKey: string;
}
export const config: WorkerConfig = {
function hostFromUrl(value: string) {
try {
return new URL(value).hostname.toLowerCase();
} catch {
return '';
}
}
function isLocalHost(hostname: string) {
return ['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(hostname);
}
function isUnsafeSecret(value: string) {
const normalized = value.trim().toLowerCase();
return (
!normalized ||
normalized.length < 32 ||
normalized.includes('replace_with') ||
normalized.includes('change-me') ||
normalized.includes('changeme') ||
normalized.includes('your_') ||
normalized.includes('example')
);
}
function validateProductionConfig(nextConfig: WorkerConfig) {
if (!nextConfig.isProduction) return;
const failures: string[] = [];
if (nextConfig.storageDefaultProvider === 'local_dev') {
failures.push('STORAGE_DEFAULT_PROVIDER=local_dev is not allowed in production workers');
}
if (!nextConfig.storageDefaultBucket.trim()) {
failures.push('STORAGE_DEFAULT_BUCKET is required in production workers');
}
if (!nextConfig.storageRequireTenantPrefix) {
failures.push('STORAGE_REQUIRE_TENANT_PREFIX=false is not allowed in production workers');
}
if (nextConfig.assetSecurityScanFailOpen) {
failures.push('WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=true is not allowed in production workers');
}
const scannerModes = nextConfig.assetSecurityScanner
.split(',')
.map(item => item.trim().toLowerCase())
.filter(Boolean);
const unsupportedScannerModes = scannerModes.filter(mode => mode !== 'metadata_rules' && mode !== 'http');
if (scannerModes.length === 0 || unsupportedScannerModes.length > 0) {
failures.push('WORKER_ASSET_SECURITY_SCANNER must include supported modes: metadata_rules,http');
}
if (!scannerModes.includes('http')) {
failures.push('WORKER_ASSET_SECURITY_SCANNER must include http in production workers');
}
if (scannerModes.includes('http')) {
const scannerHost = hostFromUrl(nextConfig.assetSecurityScanHttpEndpoint);
if (!nextConfig.assetSecurityScanHttpEndpoint.startsWith('https://') || isLocalHost(scannerHost)) {
failures.push('WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT must be a production HTTPS URL');
}
if (isUnsafeSecret(nextConfig.assetSecurityScanHttpToken)) {
failures.push('WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN must be a strong production secret');
}
}
if (nextConfig.storageDefaultProvider === 'aliyun_oss') {
if (!nextConfig.aliyunOssAccessKeyId || !nextConfig.aliyunOssAccessKeySecret) {
failures.push('ALIYUN_OSS_ACCESS_KEY_ID and ALIYUN_OSS_ACCESS_KEY_SECRET are required for aliyun_oss');
}
if (!nextConfig.aliyunOssRegion && !nextConfig.aliyunOssEndpoint) {
failures.push('ALIYUN_OSS_REGION or ALIYUN_OSS_ENDPOINT is required for aliyun_oss');
}
}
if (nextConfig.storageDefaultProvider === 'tencent_cos') {
if (!nextConfig.tencentCosRegion || !nextConfig.tencentCosAppId || !nextConfig.tencentCosSecretId || !nextConfig.tencentCosSecretKey) {
failures.push('TENCENT_COS_REGION, TENCENT_COS_APP_ID, TENCENT_COS_SECRET_ID and TENCENT_COS_SECRET_KEY are required for tencent_cos');
}
}
if (nextConfig.storageDefaultProvider === 'supabase_storage') {
if (!nextConfig.supabaseStorageUrl || !nextConfig.supabaseStorageServiceKey) {
failures.push('SUPABASE_STORAGE_URL and SUPABASE_STORAGE_SERVICE_KEY are required for supabase_storage');
}
}
if (failures.length > 0) {
throw new Error(`Invalid production worker configuration: ${failures.join('; ')}`);
}
}
const nodeEnv = envString('NODE_ENV', 'development');
const loadedConfig: WorkerConfig = {
nodeEnv,
isProduction: nodeEnv === 'production',
databaseUrl: envString('DATABASE_URL', DEFAULT_DATABASE_URL),
crmBatchSize: envNumber('WORKER_CRM_BATCH_SIZE', 20),
crmPollIntervalMs: envNumber('WORKER_CRM_POLL_INTERVAL_MS', 10_000),
@@ -139,3 +230,7 @@ export const config: WorkerConfig = {
supabaseStorageUrl: envString('SUPABASE_STORAGE_URL', ''),
supabaseStorageServiceKey: envString('SUPABASE_STORAGE_SERVICE_KEY', ''),
};
validateProductionConfig(loadedConfig);
export const config = loadedConfig;