Files
gongxue-base/scripts/production-readiness-check.js

1165 lines
47 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 PRODUCTION_SMS_PROVIDERS = new Set([
'aliyun-pnvs',
'aliyun-pnvs-sms',
'aliyun-sms-auth',
]);
const PRODUCTION_STORAGE_PROVIDERS = new Set(['aliyun_oss', 'tencent_cos', 'supabase_storage']);
const AUTH_PROVIDER_ALIASES = {
sms: new Set(['aliyun', 'aliyun-sms', 'aliyun_sms', 'aliyun-pnvs', 'aliyun_pnvs', 'aliyun-sms-auth', 'aliyun_sms_auth', 'tencent', 'tencent-sms', 'tencent_sms']),
aliyun: new Set(['aliyun', 'aliyun-sms', 'aliyun_sms']),
aliyunPnvs: new Set(['aliyun-pnvs', 'aliyun_pnvs', 'aliyun-pnvs-sms', 'aliyun_sms_auth', 'aliyun-sms-auth']),
tencent: new Set(['tencent', 'tencent-sms', 'tencent_sms']),
wechatMiniapp: new Set(['wechat-miniapp', 'wechat_miniapp', 'wechat-mini', 'wx-miniapp', 'wx_miniapp']),
wechatWeb: new Set(['wechat-web', 'wechat_web', 'wechat', 'wechat-oauth', 'wechat_oauth']),
qq: new Set(['qq', 'qq-oauth', 'qq_oauth']),
};
const PAYMENT_PROVIDER_ALIASES = {
wechatPay: new Set(['wechat_pay', 'wechat-pay', 'wechatpay', 'wxpay', 'wx_pay']),
alipay: new Set(['alipay', 'ali_pay']),
manual: new Set(['manual']),
};
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 providerConfigFixture = argValues.get('--provider-config-fixture') || '';
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 urlFromValue(value) {
try {
return new URL(String(value || ''));
} catch {
return null;
}
}
function isLocalHost(hostname) {
return ['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(hostname);
}
function isProductionHttpsUrl(value) {
const url = urlFromValue(value);
return Boolean(url && url.protocol === 'https:' && !isLocalHost(url.hostname.toLowerCase()));
}
function isAllowedHost(hostname, allowedHosts) {
const normalized = String(hostname || '').toLowerCase();
return allowedHosts.some(host => normalized === host || normalized.endsWith(`.${host}`));
}
function publicString(configPublic, keys) {
for (const key of keys) {
const value = configPublic?.[key];
if (typeof value === 'string' && value.trim()) return value.trim();
}
return '';
}
function missingPublicKeys(configPublic, keys) {
return keys.filter(key => !publicString(configPublic, [key]));
}
function normalizeProvider(value) {
return String(value || '').trim().toLowerCase();
}
function normalizeSmsProvider(value) {
return normalizeProvider(value).replace(/[_\s]/g, '-');
}
function providerIn(provider, aliases) {
return aliases.has(normalizeProvider(provider));
}
function currentSmsProviderAliases() {
const provider = normalizeSmsProvider(env('AUTH_SMS_PROVIDER', 'mock'));
if (AUTH_PROVIDER_ALIASES.aliyunPnvs.has(provider)) return { provider: 'aliyun-pnvs', aliases: AUTH_PROVIDER_ALIASES.aliyunPnvs };
return { provider, aliases: new Set([provider]) };
}
function validateActiveSmsProviderRows(rows, sourceLabel = 'database') {
const expected = currentSmsProviderAliases();
if (!PRODUCTION_SMS_PROVIDERS.has(normalizeSmsProvider(env('AUTH_SMS_PROVIDER', 'mock')))) return;
const activeRows = rows.filter(row => row.source === 'auth' && providerIn(row.provider, expected.aliases));
if (activeRows.length === 0) {
block('db.auth_sms_provider_configured', 'AUTH_SMS_PROVIDER must have a matching active/testing tenant_auth_providers row', {
provider: expected.provider,
source: sourceLabel,
});
return;
}
pass('db.auth_sms_provider_configured', 'AUTH_SMS_PROVIDER has matching active/testing tenant auth provider rows', {
provider: expected.provider,
count: activeRows.length,
source: sourceLabel,
});
}
function validateProviderUrl({
id,
value,
message,
allowedHosts = [],
required = false,
details = {},
}) {
const raw = String(value || '').trim();
if (!raw) {
if (required) block(`${id}.missing`, message || 'Production URL is required', details);
return;
}
const url = urlFromValue(raw);
if (!url || url.protocol !== 'https:' || isLocalHost(url.hostname.toLowerCase())) {
block(`${id}.unsafe`, message || 'Production URL must use HTTPS and must not point to localhost', details);
return;
}
if (allowedHosts.length > 0 && !isAllowedHost(url.hostname, allowedHosts)) {
block(`${id}.host`, 'Provider endpoint host is not allowed for production', {
...details,
host: url.hostname,
allowedHosts,
});
}
}
function missingPublicKeyGroups(configPublic, groups) {
return groups
.filter(group => !publicString(configPublic, group.keys))
.map(group => group.label);
}
function pnvsTemplateParamHasCodePlaceholder(configPublic) {
const value = configPublic?.templateParam;
if (!value || typeof value !== 'object' || Array.isArray(value)) return true;
return Object.values(value).some(item => String(item) === '##code##');
}
function blockMissingPublicConfig(row, missing) {
if (missing.length === 0) return;
block(
`db.${row.source}.${safeProviderName(row.provider)}.public_required`,
'Provider public config is missing production-required fields',
{
tenantId: row.tenant_id,
provider: row.provider,
missing,
},
);
}
function validateAuthProviderPublicConfig(row) {
const provider = normalizeProvider(row.provider);
const configPublic = row.config_public || {};
const details = { tenantId: row.tenant_id, provider: row.provider };
if (provider === 'mock') {
block('db.auth.mock_provider', 'Active/testing mock auth provider is not allowed in production', details);
return;
}
if (providerIn(provider, AUTH_PROVIDER_ALIASES.aliyun)) {
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
{ label: 'signName', keys: ['signName'] },
{ label: 'templateCode', keys: ['templateCode'] },
]));
validateProviderUrl({
id: `db.auth.${safeProviderName(row.provider)}.endpoint`,
value: publicString(configPublic, ['endpoint']),
allowedHosts: ['aliyuncs.com'],
details,
});
if (!publicString(configPublic, ['regionId'])) {
warn(`db.auth.${safeProviderName(row.provider)}.region`, 'Aliyun SMS regionId is not set; default cn-hangzhou will be used', details);
}
return;
}
if (providerIn(provider, AUTH_PROVIDER_ALIASES.aliyunPnvs)) {
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
{ label: 'signName', keys: ['signName'] },
{ label: 'templateCode', keys: ['templateCode'] },
]));
validateProviderUrl({
id: `db.auth.${safeProviderName(row.provider)}.endpoint`,
value: publicString(configPublic, ['endpoint']),
allowedHosts: ['aliyuncs.com'],
details,
});
if (!publicString(configPublic, ['regionId'])) {
warn(`db.auth.${safeProviderName(row.provider)}.region`, 'Aliyun PNVS regionId is not set; default cn-hangzhou will be used', details);
}
if (!pnvsTemplateParamHasCodePlaceholder(configPublic)) {
warn(
`db.auth.${safeProviderName(row.provider)}.template_param`,
'Aliyun PNVS templateParam should include ##code##; backend will add code placeholder automatically',
details,
);
}
return;
}
if (providerIn(provider, AUTH_PROVIDER_ALIASES.tencent)) {
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
{ label: 'smsSdkAppId/appId', keys: ['smsSdkAppId', 'appId'] },
{ label: 'signName', keys: ['signName'] },
{ label: 'templateId', keys: ['templateId'] },
]));
validateProviderUrl({
id: `db.auth.${safeProviderName(row.provider)}.endpoint`,
value: publicString(configPublic, ['endpoint']),
allowedHosts: ['tencentcloudapi.com'],
details,
});
if (!publicString(configPublic, ['region'])) {
warn(`db.auth.${safeProviderName(row.provider)}.region`, 'Tencent SMS region is not set; default ap-guangzhou will be used', details);
}
return;
}
if (providerIn(provider, AUTH_PROVIDER_ALIASES.wechatMiniapp)) {
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
{ label: 'appId', keys: ['appId'] },
]));
validateProviderUrl({
id: `db.auth.${safeProviderName(row.provider)}.endpoint`,
value: publicString(configPublic, ['endpoint']),
allowedHosts: ['weixin.qq.com'],
details,
});
return;
}
if (providerIn(provider, AUTH_PROVIDER_ALIASES.wechatWeb)) {
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
{ label: 'appId', keys: ['appId'] },
]));
validateProviderUrl({
id: `db.auth.${safeProviderName(row.provider)}.token_endpoint`,
value: publicString(configPublic, ['tokenEndpoint', 'accessTokenEndpoint', 'endpoint']),
allowedHosts: ['weixin.qq.com'],
details,
});
validateProviderUrl({
id: `db.auth.${safeProviderName(row.provider)}.userinfo_endpoint`,
value: publicString(configPublic, ['userInfoEndpoint', 'userinfoEndpoint']),
allowedHosts: ['weixin.qq.com'],
details,
});
validateProviderUrl({
id: `db.auth.${safeProviderName(row.provider)}.redirect_uri`,
value: publicString(configPublic, ['redirectUri', 'callbackUrl']),
details,
});
return;
}
if (providerIn(provider, AUTH_PROVIDER_ALIASES.qq)) {
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
{ label: 'appId/clientId', keys: ['appId', 'clientId'] },
{ label: 'redirectUri/callbackUrl', keys: ['redirectUri', 'callbackUrl'] },
]));
validateProviderUrl({
id: `db.auth.${safeProviderName(row.provider)}.redirect_uri`,
value: publicString(configPublic, ['redirectUri', 'callbackUrl']),
required: true,
details,
});
validateProviderUrl({
id: `db.auth.${safeProviderName(row.provider)}.token_endpoint`,
value: publicString(configPublic, ['tokenEndpoint', 'accessTokenEndpoint', 'endpoint']),
allowedHosts: ['graph.qq.com'],
details,
});
validateProviderUrl({
id: `db.auth.${safeProviderName(row.provider)}.openid_endpoint`,
value: publicString(configPublic, ['openIdEndpoint', 'openidEndpoint']),
allowedHosts: ['graph.qq.com'],
details,
});
validateProviderUrl({
id: `db.auth.${safeProviderName(row.provider)}.userinfo_endpoint`,
value: publicString(configPublic, ['userInfoEndpoint', 'userinfoEndpoint']),
allowedHosts: ['graph.qq.com'],
details,
});
return;
}
block('db.auth.unsupported_provider', 'Active/testing auth provider is not in the supported production provider list', details);
}
function validatePaymentProviderPublicConfig(row) {
const provider = normalizeProvider(row.provider);
const configPublic = row.config_public || {};
const details = { tenantId: row.tenant_id, provider: row.provider };
if (providerIn(provider, PAYMENT_PROVIDER_ALIASES.manual)) {
warn('db.payment.manual_provider', 'Manual payment account is active; use only for offline collection or migration operations', details);
return;
}
if (providerIn(provider, PAYMENT_PROVIDER_ALIASES.wechatPay)) {
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
{ label: 'appId', keys: ['appId'] },
{ label: 'merchantId/mchId', keys: ['merchantId', 'mchId'] },
{ label: 'merchantSerialNo', keys: ['merchantSerialNo'] },
{ label: 'notifyUrl', keys: ['notifyUrl'] },
]));
validateProviderUrl({
id: `db.payment.${safeProviderName(row.provider)}.notify_url`,
value: publicString(configPublic, ['notifyUrl']),
required: true,
details,
});
validateProviderUrl({
id: `db.payment.${safeProviderName(row.provider)}.refund_notify_url`,
value: publicString(configPublic, ['refundNotifyUrl']),
details,
});
for (const [key, allowedHosts] of [
['endpoint', ['api.mch.weixin.qq.com']],
['refundEndpoint', ['api.mch.weixin.qq.com']],
['refundQueryEndpoint', ['api.mch.weixin.qq.com']],
]) {
validateProviderUrl({
id: `db.payment.${safeProviderName(row.provider)}.${key}`,
value: publicString(configPublic, [key]),
allowedHosts,
details,
});
}
return;
}
if (providerIn(provider, PAYMENT_PROVIDER_ALIASES.alipay)) {
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
{ label: 'appId', keys: ['appId'] },
{ label: 'notifyUrl', keys: ['notifyUrl'] },
]));
validateProviderUrl({
id: `db.payment.${safeProviderName(row.provider)}.notify_url`,
value: publicString(configPublic, ['notifyUrl']),
required: true,
details,
});
validateProviderUrl({
id: `db.payment.${safeProviderName(row.provider)}.return_url`,
value: publicString(configPublic, ['returnUrl']),
details,
});
validateProviderUrl({
id: `db.payment.${safeProviderName(row.provider)}.quit_url`,
value: publicString(configPublic, ['quitUrl']),
details,
});
for (const [key, allowedHosts] of [
['endpoint', ['openapi.alipay.com']],
['refundEndpoint', ['openapi.alipay.com']],
['refundQueryEndpoint', ['openapi.alipay.com']],
]) {
validateProviderUrl({
id: `db.payment.${safeProviderName(row.provider)}.${key}`,
value: publicString(configPublic, [key]),
allowedHosts,
details,
});
}
return;
}
block('db.payment.unsupported_provider', 'Active payment account is not in the supported production provider list', details);
}
function validateProviderConfigRows(inputRows) {
const rows = inputRows.map(row => ({
source: row.source,
tenant_id: row.tenant_id || row.tenantId || 'fixture-tenant',
provider: row.provider,
config_public: row.config_public || row.configPublic || {},
}));
if (rows.length === 0) {
warn('db.provider_fixture.empty', 'Provider config fixture did not contain any rows');
return;
}
validateActiveSmsProviderRows(rows, 'fixture');
for (const row of 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 (row.source === 'auth') validateAuthProviderPublicConfig(row);
else if (row.source === 'payment') validatePaymentProviderPublicConfig(row);
else block('db.provider_fixture.source', 'Provider config fixture row source must be auth or payment', { source: row.source });
}
if (rows.every(row => findSecretLikePaths(row.config_public || {}).length === 0)) {
pass('db.provider_public_config', 'Active provider public configs do not contain secret-like keys');
}
}
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 formatTableName(row) {
return `${row.table_schema}.${row.table_name}`;
}
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 = normalizeSmsProvider(env('AUTH_SMS_PROVIDER', 'mock'));
if (!PRODUCTION_SMS_PROVIDERS.has(authSmsProvider)) {
block('env.auth_sms_provider', 'AUTH_SMS_PROVIDER must be aliyun-pnvs in production', {
provider: authSmsProvider || '(empty)',
});
} else {
pass('env.auth_sms_provider', 'AUTH_SMS_PROVIDER is aliyun-pnvs for production SMS authentication', { provider: authSmsProvider });
}
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');
}
if (!env('AUTH_JWT_ISSUER', '').trim()) {
block('env.auth_jwt_issuer', 'AUTH_JWT_ISSUER is required when AUTH_JWT_JWKS_URL is configured');
} else {
pass('env.auth_jwt_issuer', 'AUTH_JWT_ISSUER is configured for JWKS verification');
}
} 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').trim();
if (storageProvider === 'local_dev') {
block('env.storage_provider', 'STORAGE_DEFAULT_PROVIDER=local_dev is not allowed for production assets');
} else if (!PRODUCTION_STORAGE_PROVIDERS.has(storageProvider)) {
block('env.storage_provider.unsupported', 'STORAGE_DEFAULT_PROVIDER must be aliyun_oss, tencent_cos or supabase_storage in production', {
provider: storageProvider || '(empty)',
});
} 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 (env('STORAGE_PUBLIC_BASE_URL', '') && !isProductionHttpsUrl(env('STORAGE_PUBLIC_BASE_URL', ''))) {
block('env.storage_public_base_url', 'STORAGE_PUBLIC_BASE_URL must be a production HTTPS URL when configured');
}
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`);
}
validateProviderUrl({
id: 'env.aliyun_oss_endpoint',
value: env('ALIYUN_OSS_ENDPOINT', ''),
message: 'ALIYUN_OSS_ENDPOINT must be a production HTTPS endpoint',
allowedHosts: ['aliyuncs.com'],
required: true,
});
if (envBool('ALIYUN_OSS_INTERNAL', false)) {
block('env.aliyun_oss_internal', 'ALIYUN_OSS_INTERNAL=true is not allowed for user-facing production signed URLs');
}
}
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`);
}
validateProviderUrl({
id: 'env.supabase_storage_url',
value: env('SUPABASE_STORAGE_URL', ''),
message: 'SUPABASE_STORAGE_URL must be a production HTTPS URL',
required: true,
});
}
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');
}
const scannerModes = envList('WORKER_ASSET_SECURITY_SCANNER', 'metadata_rules').map(item => item.toLowerCase());
const unsupportedScannerModes = scannerModes.filter(mode => mode !== 'metadata_rules' && mode !== 'http');
if (scannerModes.length === 0) {
block('env.asset_security_scanner.empty', 'WORKER_ASSET_SECURITY_SCANNER must include metadata_rules and a production scanner');
} else if (unsupportedScannerModes.length > 0) {
block('env.asset_security_scanner.unsupported', 'WORKER_ASSET_SECURITY_SCANNER contains unsupported modes', {
modes: unsupportedScannerModes,
});
} else if (!scannerModes.includes('http')) {
block('env.asset_security_scanner.external', 'Production assets require WORKER_ASSET_SECURITY_SCANNER=http or metadata_rules,http');
} else {
pass('env.asset_security_scanner', 'external asset security scanner is enabled', { modes: scannerModes });
}
const scannerEndpoint = env('WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT', '');
if (scannerModes.includes('http')) {
const scannerHost = hostFromUrl(scannerEndpoint);
if (!scannerEndpoint) {
block('env.asset_security_scan_http_endpoint', 'WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT is required when http scanner is enabled');
} else if (!scannerEndpoint.startsWith('https://') || isLocalHost(scannerHost)) {
block('env.asset_security_scan_http_endpoint.unsafe', 'WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT must be a production HTTPS URL');
} else {
pass('env.asset_security_scan_http_endpoint', 'asset security scanner endpoint is HTTPS');
}
if (isUnsafeSecret(env('WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN', ''), '')) {
block('env.asset_security_scan_http_token', 'WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN must be a strong shared secret or service token');
} else {
pass('env.asset_security_scan_http_token', 'asset security scanner token looks production-grade');
}
}
const scannerTimeout = envNumber('WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS', 10_000);
if (scannerTimeout <= 0 || scannerTimeout > 60_000) {
block('env.asset_security_scan_http_timeout', 'WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS must be between 1 and 60000');
} else {
pass('env.asset_security_scan_http_timeout', 'asset security scanner timeout is bounded');
}
if (envBool('WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN', false)) {
block('env.asset_security_scan_fail_open', 'WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN must be false in production');
} else {
pass('env.asset_security_scan_fail_open', 'asset security scanning fails closed');
}
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');
}
if (envBool('WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST', false)) {
block('env.worker_platform_audit_notification_insecure_localhost', 'WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST must be false in production');
} else {
pass('env.worker_platform_audit_notification_insecure_localhost', 'Platform audit notification worker insecure localhost webhook mode is disabled');
}
if (envBool('WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST', false)) {
block('env.worker_platform_dunning_notification_insecure_localhost', 'WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST must be false in production');
} else {
pass('env.worker_platform_dunning_notification_insecure_localhost', 'Platform dunning notification 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 (row.source === 'auth') validateAuthProviderPublicConfig(row);
if (row.source === 'payment') validatePaymentProviderPublicConfig(row);
}
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');
}
validateActiveSmsProviderRows(publicSecretRows.rows, 'database');
const activePlatformAdminRows = await pool.query(`
select id, username, phone, auth_user_id
from public.platform_users
where primary_role = 'platform_admin'
and status = 'active'
order by created_at asc
limit 20
`);
if (activePlatformAdminRows.rowCount === 0) {
block('db.platform_admin_active', 'At least one active platform admin user is required');
} else {
pass('db.platform_admin_active', 'Active platform admin users found', { count: activePlatformAdminRows.rowCount });
}
const activePlatformAdminsWithoutAuth = await pool.query(`
select id, username, phone
from public.platform_users
where primary_role = 'platform_admin'
and status = 'active'
and auth_user_id is null
order by created_at asc
limit 20
`);
if (activePlatformAdminsWithoutAuth.rowCount > 0) {
block('db.platform_admin_auth_binding', 'Active platform admin users must be bound to Supabase Auth users', {
count: activePlatformAdminsWithoutAuth.rowCount,
samples: activePlatformAdminsWithoutAuth.rows.map(row => ({
id: row.id,
username: row.username,
phone: row.phone ? `${String(row.phone).slice(0, 3)}****${String(row.phone).slice(-4)}` : null,
})),
});
} else {
pass('db.platform_admin_auth_binding', 'Active platform admin users are bound to Supabase Auth users');
}
const platformAdminsWithoutPermissions = await pool.query(`
select id, username, phone
from public.platform_users
where primary_role = 'platform_admin'
and status = 'active'
and (platform_permissions is null or platform_permissions = '{}'::jsonb)
order by created_at asc
limit 20
`);
if (platformAdminsWithoutPermissions.rowCount > 0) {
block('db.platform_admin_permissions', 'Platform admin users must have explicit platform_permissions', {
count: platformAdminsWithoutPermissions.rowCount,
samples: platformAdminsWithoutPermissions.rows.map(row => ({
id: row.id,
username: row.username,
phone: row.phone ? `${String(row.phone).slice(0, 3)}****${String(row.phone).slice(-4)}` : null,
})),
});
} else {
pass('db.platform_admin_permissions', 'Platform admin users have explicit permission maps');
}
const disabledPlatformAdminsWithActiveSessions = await pool.query(`
select u.id, u.username, u.phone, count(s.id)::int as active_session_count
from public.platform_users u
join app_private.auth_sessions s on s.user_id = u.id
where u.primary_role = 'platform_admin'
and u.status = 'disabled'
and s.revoked_at is null
and s.expires_at > now()
group by u.id, u.username, u.phone
order by active_session_count desc
limit 20
`);
if (disabledPlatformAdminsWithActiveSessions.rowCount > 0) {
block('db.platform_admin_disabled_sessions', 'Disabled platform admin users must not have active legacy sessions', {
count: disabledPlatformAdminsWithActiveSessions.rowCount,
samples: disabledPlatformAdminsWithActiveSessions.rows.map(row => ({
id: row.id,
username: row.username,
phone: row.phone ? `${String(row.phone).slice(0, 3)}****${String(row.phone).slice(-4)}` : null,
activeSessionCount: row.active_session_count,
})),
});
} else {
pass('db.platform_admin_disabled_sessions', 'Disabled platform admin users have no active legacy sessions');
}
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', 'aliyun-pnvs', 'aliyun-pnvs-sms', 'aliyun-sms-auth', '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 unsafePlatformAuditNotificationRows = await pool.query(`
select id, channel_code, provider, webhook_url
from public.platform_audit_notification_channels
where enabled = true
and (
webhook_url !~* '^https://'
or webhook_url ~* '^https?://(localhost|127\\.0\\.0\\.1|\\[?::1\\]?)'
)
`);
if (unsafePlatformAuditNotificationRows.rowCount > 0) {
block('db.platform_audit_notification_webhooks', 'Enabled platform audit notification webhooks must use production HTTPS URLs', {
count: unsafePlatformAuditNotificationRows.rowCount,
samples: unsafePlatformAuditNotificationRows.rows.slice(0, 5).map(row => ({
id: row.id,
channelCode: row.channel_code,
provider: row.provider,
})),
});
} else {
pass('db.platform_audit_notification_webhooks', 'Enabled platform audit notification webhooks use production HTTPS URLs');
}
const missingPlatformAuditNotificationSecretRows = await pool.query(`
select c.id, c.channel_code, c.provider, c.secret_ref
from public.platform_audit_notification_channels c
left join app_private.platform_secrets s
on s.secret_scope = split_part(c.secret_ref, ':', 2)
and s.secret_key = split_part(c.secret_ref, ':', 3)
where c.enabled = true
and c.provider in ('dingtalk', 'feishu')
and (c.secret_ref is null or c.secret_ref !~ '^app_private\\.platform_secrets:' or s.id is null)
`);
if (missingPlatformAuditNotificationSecretRows.rowCount > 0) {
block('db.platform_audit_notification_secrets', 'Signed platform audit notification channels require app_private.platform_secrets rows', {
count: missingPlatformAuditNotificationSecretRows.rowCount,
samples: missingPlatformAuditNotificationSecretRows.rows.slice(0, 5).map(row => ({
id: row.id,
channelCode: row.channel_code,
provider: row.provider,
})),
});
} else {
pass('db.platform_audit_notification_secrets', 'Signed platform audit notification channels have private secret rows');
}
const unsafePlatformDunningNotificationRows = await pool.query(`
select id, channel_code, provider, webhook_url
from public.platform_dunning_notification_channels
where enabled = true
and (
webhook_url !~* '^https://'
or webhook_url ~* '^https?://(localhost|127\\.0\\.0\\.1|\\[?::1\\]?)'
)
`);
if (unsafePlatformDunningNotificationRows.rowCount > 0) {
block('db.platform_dunning_notification_webhooks', 'Enabled platform dunning notification webhooks must use production HTTPS URLs', {
count: unsafePlatformDunningNotificationRows.rowCount,
samples: unsafePlatformDunningNotificationRows.rows.slice(0, 5).map(row => ({
id: row.id,
channelCode: row.channel_code,
provider: row.provider,
})),
});
} else {
pass('db.platform_dunning_notification_webhooks', 'Enabled platform dunning notification webhooks use production HTTPS URLs');
}
const missingPlatformDunningNotificationSecretRows = await pool.query(`
select c.id, c.channel_code, c.provider, c.secret_ref
from public.platform_dunning_notification_channels c
left join app_private.platform_secrets s
on s.secret_scope = split_part(c.secret_ref, ':', 2)
and s.secret_key = split_part(c.secret_ref, ':', 3)
where c.enabled = true
and c.provider in ('dingtalk', 'feishu')
and (c.secret_ref is null or c.secret_ref !~ '^app_private\\.platform_secrets:' or s.id is null)
`);
if (missingPlatformDunningNotificationSecretRows.rowCount > 0) {
block('db.platform_dunning_notification_secrets', 'Signed platform dunning notification channels require app_private.platform_secrets rows', {
count: missingPlatformDunningNotificationSecretRows.rowCount,
samples: missingPlatformDunningNotificationSecretRows.rows.slice(0, 5).map(row => ({
id: row.id,
channelCode: row.channel_code,
provider: row.provider,
})),
});
} else {
pass('db.platform_dunning_notification_secrets', 'Signed platform dunning notification channels 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');
const tenantTablesWithoutRls = await pool.query(`
select c.table_schema, c.table_name
from information_schema.columns c
join pg_class cls on cls.relname = c.table_name
join pg_namespace ns on ns.oid = cls.relnamespace and ns.nspname = c.table_schema
where c.column_name = 'tenant_id'
and c.table_schema in ('public', 'app_private')
and cls.relkind in ('r', 'p')
and not cls.relrowsecurity
order by c.table_schema, c.table_name
`);
if (tenantTablesWithoutRls.rowCount > 0) {
block('db.rls.tenant_tables_enabled', 'Every tenant-scoped table must have row level security enabled', {
count: tenantTablesWithoutRls.rowCount,
samples: tenantTablesWithoutRls.rows.slice(0, 10).map(formatTableName),
});
} else {
pass('db.rls.tenant_tables_enabled', 'All tenant-scoped tables have RLS enabled');
}
const tenantTablesWithoutPolicies = await pool.query(`
select c.table_schema, c.table_name
from information_schema.columns c
join pg_class cls on cls.relname = c.table_name
join pg_namespace ns on ns.oid = cls.relnamespace and ns.nspname = c.table_schema
where c.column_name = 'tenant_id'
and c.table_schema in ('public', 'app_private')
and cls.relkind in ('r', 'p')
and not exists (
select 1
from pg_policy p
where p.polrelid = cls.oid
)
order by c.table_schema, c.table_name
`);
if (tenantTablesWithoutPolicies.rowCount > 0) {
block('db.rls.tenant_tables_policy', 'Every tenant-scoped table must have at least one RLS policy', {
count: tenantTablesWithoutPolicies.rowCount,
samples: tenantTablesWithoutPolicies.rows.slice(0, 10).map(formatTableName),
});
} else {
pass('db.rls.tenant_tables_policy', 'All tenant-scoped tables have at least one RLS policy');
}
const publicTenantTablesWithoutTenantContextPolicy = await pool.query(`
select c.table_schema, c.table_name
from information_schema.columns c
join pg_class cls on cls.relname = c.table_name
join pg_namespace ns on ns.oid = cls.relnamespace and ns.nspname = c.table_schema
where c.column_name = 'tenant_id'
and c.table_schema = 'public'
and cls.relkind in ('r', 'p')
and not exists (
select 1
from pg_policy p
where p.polrelid = cls.oid
and (
lower(pg_get_expr(p.polqual, p.polrelid)) like '%app.current_tenant_id()%'
or lower(pg_get_expr(p.polwithcheck, p.polrelid)) like '%app.current_tenant_id()%'
)
)
order by c.table_schema, c.table_name
`);
if (publicTenantTablesWithoutTenantContextPolicy.rowCount > 0) {
block(
'db.rls.public_tenant_context',
'Every public tenant-scoped table must include app.current_tenant_id() in an RLS policy',
{
count: publicTenantTablesWithoutTenantContextPolicy.rowCount,
samples: publicTenantTablesWithoutTenantContextPolicy.rows.slice(0, 10).map(formatTableName),
},
);
} else {
pass('db.rls.public_tenant_context', 'Public tenant-scoped tables include tenant context in RLS policies');
}
} 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();
if (providerConfigFixture) {
const fixturePath = path.resolve(process.cwd(), providerConfigFixture);
const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
const rows = Array.isArray(fixture) ? fixture : Array.isArray(fixture.rows) ? fixture.rows : [];
validateProviderConfigRows(rows);
}
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;
});