forked from wangziqi/gongxue-base
420 lines
16 KiB
JavaScript
420 lines
16 KiB
JavaScript
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import process from 'node:process';
|
|
import pg from 'pg';
|
|
|
|
const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
|
const DEFAULT_AUTH_CODE_PEPPER = 'development-code-pepper-change-me';
|
|
const DEFAULT_AUTH_SESSION_SECRET = 'development-session-secret-change-me';
|
|
const DEFAULT_AUTH_JWT_SECRET = 'development-jwt-secret-change-me';
|
|
const DEFAULT_PLATFORM_ADMIN_API_KEY = 'local-platform-admin-key';
|
|
const HARD_MAX_JSON_BODY_BYTES = 50 * 1024 * 1024;
|
|
|
|
const args = new Set(process.argv.slice(2));
|
|
const argValues = new Map();
|
|
for (let index = 2; index < process.argv.length; index += 1) {
|
|
const current = process.argv[index];
|
|
if (current.startsWith('--') && process.argv[index + 1] && !process.argv[index + 1].startsWith('--')) {
|
|
argValues.set(current, process.argv[index + 1]);
|
|
index += 1;
|
|
}
|
|
}
|
|
|
|
const jsonOutput = args.has('--json');
|
|
const checkDb = args.has('--check-db');
|
|
const skipDb = args.has('--skip-db') || !checkDb;
|
|
const envFile = argValues.get('--env-file') || path.resolve(process.cwd(), '.env');
|
|
const checks = [];
|
|
|
|
function loadEnvFile(filePath) {
|
|
if (!filePath || !fs.existsSync(filePath)) return;
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
for (const line of content.split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
const index = trimmed.indexOf('=');
|
|
if (index === -1) continue;
|
|
const key = trimmed.slice(0, index).trim();
|
|
const value = trimmed.slice(index + 1).trim().replace(/^"|"$/g, '');
|
|
if (key && process.env[key] === undefined) process.env[key] = value;
|
|
}
|
|
}
|
|
|
|
function env(key, fallback = '') {
|
|
return process.env[key] ?? fallback;
|
|
}
|
|
|
|
function envBool(key, fallback = false) {
|
|
const value = process.env[key];
|
|
if (value === undefined) return fallback;
|
|
return ['true', '1', 'yes', 'y', 'on'].includes(value.toLowerCase());
|
|
}
|
|
|
|
function envNumber(key, fallback) {
|
|
const parsed = Number(process.env[key]);
|
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
}
|
|
|
|
function envList(key, fallback = '') {
|
|
return env(key, fallback)
|
|
.split(',')
|
|
.map(item => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function add(status, id, message, details = {}) {
|
|
checks.push({ status, id, message, details });
|
|
}
|
|
|
|
function pass(id, message, details = {}) {
|
|
add('pass', id, message, details);
|
|
}
|
|
|
|
function warn(id, message, details = {}) {
|
|
add('warn', id, message, details);
|
|
}
|
|
|
|
function block(id, message, details = {}) {
|
|
add('blocker', id, message, details);
|
|
}
|
|
|
|
function isUnsafeSecret(value, defaultValue) {
|
|
const normalized = String(value || '').trim().toLowerCase();
|
|
return (
|
|
!normalized ||
|
|
value === defaultValue ||
|
|
normalized.length < 32 ||
|
|
normalized.includes('replace_with') ||
|
|
normalized.includes('change-me') ||
|
|
normalized.includes('changeme') ||
|
|
normalized.includes('your_') ||
|
|
normalized.includes('example')
|
|
);
|
|
}
|
|
|
|
function hostFromUrl(value) {
|
|
try {
|
|
return new URL(value).hostname.toLowerCase();
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function isLocalHost(hostname) {
|
|
return ['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(hostname);
|
|
}
|
|
|
|
function isSecretLikeKey(key) {
|
|
const normalized = key.toLowerCase().replace(/[-_\s]/g, '');
|
|
const allowedSecretRef = normalized === 'secretref' || normalized.endsWith('secretref');
|
|
return (
|
|
!allowedSecretRef &&
|
|
(normalized.includes('secret') ||
|
|
normalized.includes('password') ||
|
|
normalized.includes('token') ||
|
|
normalized.includes('privatekey') ||
|
|
normalized.includes('apikey') ||
|
|
normalized.includes('apiv3key') ||
|
|
normalized.includes('mchkey') ||
|
|
normalized.includes('signkey') ||
|
|
normalized.includes('aeskey') ||
|
|
normalized.includes('partnerkey'))
|
|
);
|
|
}
|
|
|
|
function findSecretLikePaths(value, prefix = 'configPublic') {
|
|
if (!value || typeof value !== 'object') return [];
|
|
if (Array.isArray(value)) {
|
|
return value.flatMap((item, index) => findSecretLikePaths(item, `${prefix}[${index}]`));
|
|
}
|
|
const paths = [];
|
|
for (const [key, child] of Object.entries(value)) {
|
|
const nextPath = `${prefix}.${key}`;
|
|
if (isSecretLikeKey(key)) paths.push(nextPath);
|
|
paths.push(...findSecretLikePaths(child, nextPath));
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
function safeProviderName(provider) {
|
|
return String(provider || '').replace(/[^a-zA-Z0-9_.:-]/g, '_');
|
|
}
|
|
|
|
function validateEnv() {
|
|
const nodeEnv = env('NODE_ENV', 'development');
|
|
if (nodeEnv !== 'production') block('env.node_env', 'NODE_ENV must be production for production readiness checks');
|
|
else pass('env.node_env', 'NODE_ENV is production');
|
|
|
|
const databaseUrl = env('DATABASE_URL', DEFAULT_DATABASE_URL);
|
|
const databaseHost = hostFromUrl(databaseUrl);
|
|
if (!databaseUrl || databaseUrl === DEFAULT_DATABASE_URL) {
|
|
block('env.database_url', 'DATABASE_URL must point to the production Supabase/PostgreSQL database');
|
|
} else if (isLocalHost(databaseHost)) {
|
|
warn('env.database_url.localhost', 'DATABASE_URL points to a local host; confirm this is intentional for self-hosted deployment');
|
|
} else {
|
|
pass('env.database_url', 'DATABASE_URL is not the local development default');
|
|
}
|
|
|
|
const corsOrigins = envList('CORS_ORIGIN', '*');
|
|
if (corsOrigins.includes('*')) {
|
|
block('env.cors_origin', 'CORS_ORIGIN must not include * in production');
|
|
} else if (corsOrigins.length === 0) {
|
|
block('env.cors_origin.empty', 'CORS_ORIGIN must include the deployed H5/admin domains');
|
|
} else {
|
|
const unsafeOrigins = corsOrigins.filter(origin => {
|
|
const host = hostFromUrl(origin);
|
|
return !origin.startsWith('https://') || isLocalHost(host);
|
|
});
|
|
if (unsafeOrigins.length > 0) {
|
|
block('env.cors_origin.unsafe', 'CORS_ORIGIN must use production HTTPS origins only', { count: unsafeOrigins.length });
|
|
} else {
|
|
pass('env.cors_origin', 'CORS_ORIGIN is restricted to HTTPS origins', { count: corsOrigins.length });
|
|
}
|
|
}
|
|
|
|
const authSmsProvider = env('AUTH_SMS_PROVIDER', 'mock');
|
|
if (authSmsProvider === 'mock') block('env.auth_sms_provider', 'AUTH_SMS_PROVIDER=mock is not allowed in production');
|
|
else pass('env.auth_sms_provider', 'AUTH_SMS_PROVIDER is not mock');
|
|
|
|
if (isUnsafeSecret(env('AUTH_CODE_PEPPER', DEFAULT_AUTH_CODE_PEPPER), DEFAULT_AUTH_CODE_PEPPER)) {
|
|
block('env.auth_code_pepper', 'AUTH_CODE_PEPPER must be a strong production secret');
|
|
} else {
|
|
pass('env.auth_code_pepper', 'AUTH_CODE_PEPPER looks production-grade');
|
|
}
|
|
|
|
if (isUnsafeSecret(env('AUTH_SESSION_SECRET', DEFAULT_AUTH_SESSION_SECRET), DEFAULT_AUTH_SESSION_SECRET)) {
|
|
block('env.auth_session_secret', 'AUTH_SESSION_SECRET must be a strong production secret');
|
|
} else {
|
|
pass('env.auth_session_secret', 'AUTH_SESSION_SECRET looks production-grade');
|
|
}
|
|
|
|
const jwksUrl = env('AUTH_JWT_JWKS_URL', '');
|
|
const jwtSecret = env('AUTH_JWT_SECRET', DEFAULT_AUTH_JWT_SECRET);
|
|
if (jwksUrl) {
|
|
const host = hostFromUrl(jwksUrl);
|
|
if (!jwksUrl.startsWith('https://') || isLocalHost(host)) {
|
|
block('env.auth_jwt_jwks_url', 'AUTH_JWT_JWKS_URL must be an HTTPS production URL');
|
|
} else {
|
|
pass('env.auth_jwt_jwks_url', 'AUTH_JWT_JWKS_URL is configured');
|
|
}
|
|
} else if (isUnsafeSecret(jwtSecret, DEFAULT_AUTH_JWT_SECRET)) {
|
|
block('env.auth_jwt_secret', 'AUTH_JWT_SECRET or AUTH_JWT_JWKS_URL must be configured for production JWT verification');
|
|
} else {
|
|
pass('env.auth_jwt_secret', 'AUTH_JWT_SECRET looks production-grade');
|
|
}
|
|
|
|
if (envBool('ALLOW_LEGACY_AUTH_HEADERS', false)) {
|
|
block('env.allow_legacy_auth_headers', 'ALLOW_LEGACY_AUTH_HEADERS must be false in production');
|
|
} else {
|
|
pass('env.allow_legacy_auth_headers', 'legacy x-user-id auth headers are disabled');
|
|
}
|
|
|
|
if (envBool('ALLOW_PLATFORM_ADMIN_KEY', false)) {
|
|
block('env.allow_platform_admin_key', 'ALLOW_PLATFORM_ADMIN_KEY must be false in production');
|
|
} else {
|
|
pass('env.allow_platform_admin_key', 'platform admin API key compatibility is disabled');
|
|
}
|
|
|
|
if (isUnsafeSecret(env('PLATFORM_ADMIN_API_KEY', DEFAULT_PLATFORM_ADMIN_API_KEY), DEFAULT_PLATFORM_ADMIN_API_KEY)) {
|
|
warn('env.platform_admin_api_key', 'PLATFORM_ADMIN_API_KEY is weak/default; keep ALLOW_PLATFORM_ADMIN_KEY=false and rotate before any temporary use');
|
|
} else {
|
|
pass('env.platform_admin_api_key', 'PLATFORM_ADMIN_API_KEY is not default');
|
|
}
|
|
|
|
const maxJson = envNumber('MAX_JSON_BODY_BYTES', 1024 * 1024);
|
|
const maxImportJson = envNumber('MAX_IMPORT_JSON_BODY_BYTES', 10 * 1024 * 1024);
|
|
if (maxJson > HARD_MAX_JSON_BODY_BYTES || maxImportJson > HARD_MAX_JSON_BODY_BYTES) {
|
|
block('env.body_size', 'JSON body limits must stay at or below 50MB');
|
|
} else {
|
|
pass('env.body_size', 'JSON body limits are bounded');
|
|
}
|
|
|
|
const storageProvider = env('STORAGE_DEFAULT_PROVIDER', 'local_dev');
|
|
if (storageProvider === 'local_dev') {
|
|
block('env.storage_provider', 'STORAGE_DEFAULT_PROVIDER=local_dev is not allowed for production assets');
|
|
} else {
|
|
pass('env.storage_provider', 'STORAGE_DEFAULT_PROVIDER is production-capable', { provider: storageProvider });
|
|
}
|
|
if (!env('STORAGE_DEFAULT_BUCKET', '')) {
|
|
block('env.storage_bucket', 'STORAGE_DEFAULT_BUCKET is required');
|
|
} else {
|
|
pass('env.storage_bucket', 'STORAGE_DEFAULT_BUCKET is configured');
|
|
}
|
|
if (!envBool('STORAGE_REQUIRE_TENANT_PREFIX', true)) {
|
|
block('env.storage_tenant_prefix', 'STORAGE_REQUIRE_TENANT_PREFIX must remain true to protect tenant assets');
|
|
} else {
|
|
pass('env.storage_tenant_prefix', 'tenant-prefixed object keys are required');
|
|
}
|
|
|
|
if (storageProvider === 'aliyun_oss') {
|
|
for (const key of ['ALIYUN_OSS_REGION', 'ALIYUN_OSS_ENDPOINT', 'ALIYUN_OSS_ACCESS_KEY_ID', 'ALIYUN_OSS_ACCESS_KEY_SECRET']) {
|
|
if (!env(key, '')) block(`env.${key.toLowerCase()}`, `${key} is required for aliyun_oss`);
|
|
}
|
|
}
|
|
if (storageProvider === 'tencent_cos') {
|
|
for (const key of ['TENCENT_COS_REGION', 'TENCENT_COS_APP_ID', 'TENCENT_COS_SECRET_ID', 'TENCENT_COS_SECRET_KEY']) {
|
|
if (!env(key, '')) block(`env.${key.toLowerCase()}`, `${key} is required for tencent_cos`);
|
|
}
|
|
}
|
|
if (storageProvider === 'supabase_storage') {
|
|
for (const key of ['SUPABASE_STORAGE_URL', 'SUPABASE_STORAGE_SERVICE_KEY']) {
|
|
if (!env(key, '')) block(`env.${key.toLowerCase()}`, `${key} is required for supabase_storage`);
|
|
}
|
|
}
|
|
|
|
if (envList('STORAGE_ALLOWED_MIME_TYPES').includes('application/octet-stream')) {
|
|
warn('env.storage_octet_stream', 'application/octet-stream is allowed; consider removing it after import migration is stable');
|
|
}
|
|
|
|
if (envBool('WORKER_CRM_ALLOW_INSECURE_LOCALHOST', false)) {
|
|
block('env.worker_crm_insecure_localhost', 'WORKER_CRM_ALLOW_INSECURE_LOCALHOST must be false in production');
|
|
} else {
|
|
pass('env.worker_crm_insecure_localhost', 'CRM worker insecure localhost webhook mode is disabled');
|
|
}
|
|
|
|
const requiredPositiveNumbers = [
|
|
'WORKER_CRM_BATCH_SIZE',
|
|
'WORKER_COMMERCE_BATCH_SIZE',
|
|
'WORKER_ASSET_BATCH_SIZE',
|
|
'WORKER_IMPORT_BATCH_SIZE',
|
|
'WORKER_PUBLIC_BANK_SYNC_BATCH_SIZE',
|
|
];
|
|
for (const key of requiredPositiveNumbers) {
|
|
if (envNumber(key, 1) <= 0) block(`env.${key.toLowerCase()}`, `${key} must be greater than 0`);
|
|
}
|
|
}
|
|
|
|
async function validateDatabase() {
|
|
if (skipDb) {
|
|
warn('db.skipped', 'Database readiness checks skipped; run with --check-db after production DATABASE_URL is configured');
|
|
return;
|
|
}
|
|
|
|
const pool = new pg.Pool({ connectionString: env('DATABASE_URL', DEFAULT_DATABASE_URL), max: 2 });
|
|
try {
|
|
const tenantRows = await pool.query(`
|
|
select id, name, status
|
|
from public.tenants
|
|
where status = 'active'
|
|
order by created_at asc
|
|
`);
|
|
if (tenantRows.rowCount === 0) block('db.tenants', 'No active tenant exists in the production database');
|
|
else pass('db.tenants', 'Active tenants found', { count: tenantRows.rowCount });
|
|
|
|
const publicSecretRows = await pool.query(`
|
|
select source, tenant_id, provider, config_public
|
|
from (
|
|
select 'auth' as source, tenant_id, provider, config_public
|
|
from public.tenant_auth_providers
|
|
where status in ('active', 'testing')
|
|
union all
|
|
select 'payment' as source, tenant_id, provider, config_public
|
|
from public.tenant_payment_accounts
|
|
where status = 'active'
|
|
) providers
|
|
`);
|
|
for (const row of publicSecretRows.rows) {
|
|
const secretPaths = findSecretLikePaths(row.config_public || {});
|
|
if (secretPaths.length > 0) {
|
|
block(`db.${row.source}.${safeProviderName(row.provider)}.public_secret`, 'Provider public config contains secret-like keys', {
|
|
tenantId: row.tenant_id,
|
|
provider: row.provider,
|
|
secretPathCount: secretPaths.length,
|
|
});
|
|
}
|
|
}
|
|
if (publicSecretRows.rows.every(row => findSecretLikePaths(row.config_public || {}).length === 0)) {
|
|
pass('db.provider_public_config', 'Active provider public configs do not contain secret-like keys');
|
|
}
|
|
|
|
const missingAuthSecretRows = await pool.query(`
|
|
select p.tenant_id, p.provider
|
|
from public.tenant_auth_providers p
|
|
left join app_private.tenant_secrets s
|
|
on s.tenant_id = p.tenant_id
|
|
and s.secret_scope = case
|
|
when lower(replace(p.provider, '_', '-')) in ('aliyun', 'aliyun-sms', 'tencent', 'tencent-sms') then 'sms'
|
|
else 'oauth'
|
|
end
|
|
and s.secret_key = coalesce(nullif(split_part(p.config_public->>'secretRef', ':', 3), ''), p.provider)
|
|
where p.status in ('active', 'testing')
|
|
and p.provider not in ('mock')
|
|
and s.id is null
|
|
`);
|
|
if (missingAuthSecretRows.rowCount > 0) {
|
|
block('db.auth_provider_secrets', 'Some active auth providers are missing tenant secret rows', { count: missingAuthSecretRows.rowCount });
|
|
} else {
|
|
pass('db.auth_provider_secrets', 'Active auth providers have private secret rows');
|
|
}
|
|
|
|
const missingPaymentSecretRows = await pool.query(`
|
|
select p.tenant_id, p.provider
|
|
from public.tenant_payment_accounts p
|
|
left join app_private.tenant_secrets s
|
|
on s.tenant_id = p.tenant_id
|
|
and s.secret_scope = 'payment'
|
|
and s.secret_key = coalesce(nullif(split_part(p.config_public->>'secretRef', ':', 3), ''), p.provider)
|
|
where p.status = 'active'
|
|
and p.provider not in ('manual')
|
|
and s.id is null
|
|
`);
|
|
if (missingPaymentSecretRows.rowCount > 0) {
|
|
block('db.payment_provider_secrets', 'Some active payment accounts are missing tenant secret rows', { count: missingPaymentSecretRows.rowCount });
|
|
} else {
|
|
pass('db.payment_provider_secrets', 'Active payment accounts have private secret rows');
|
|
}
|
|
|
|
const unverifiedDomainRows = await pool.query(`
|
|
select count(*)::int as count
|
|
from public.tenant_domains
|
|
where status not in ('active', 'verified')
|
|
`);
|
|
const unverifiedDomains = Number(unverifiedDomainRows.rows[0]?.count || 0);
|
|
if (unverifiedDomains > 0) warn('db.tenant_domains', 'Some tenant domains are not active/verified', { count: unverifiedDomains });
|
|
else pass('db.tenant_domains', 'Tenant domains are active/verified or not configured');
|
|
} finally {
|
|
await pool.end();
|
|
}
|
|
}
|
|
|
|
function printSummary() {
|
|
const summary = checks.reduce(
|
|
(acc, item) => {
|
|
acc[item.status] += 1;
|
|
return acc;
|
|
},
|
|
{ blocker: 0, warn: 0, pass: 0 },
|
|
);
|
|
|
|
if (jsonOutput) {
|
|
console.log(JSON.stringify({ summary, checks }, null, 2));
|
|
return;
|
|
}
|
|
|
|
console.log('Production readiness check');
|
|
console.log(`Env file: ${fs.existsSync(envFile) ? envFile : '(not found, using process env)'}`);
|
|
console.log(`Host: ${os.hostname()}`);
|
|
console.log(`Summary: ${summary.blocker} blocker(s), ${summary.warn} warning(s), ${summary.pass} pass(es)`);
|
|
for (const item of checks) {
|
|
const marker = item.status === 'blocker' ? 'BLOCK' : item.status === 'warn' ? 'WARN ' : 'PASS ';
|
|
console.log(`[${marker}] ${item.id}: ${item.message}`);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
loadEnvFile(envFile);
|
|
validateEnv();
|
|
await validateDatabase();
|
|
printSummary();
|
|
|
|
if (checks.some(item => item.status === 'blocker')) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
main().catch(error => {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
process.exitCode = 1;
|
|
});
|